1use super::*;
4
5pub struct Flat {
11 pub text: SharedString,
12 pub runs: Vec<TextRun>,
13 pub links: Vec<(Range<usize>, String)>,
14 pub code: Vec<Range<usize>>,
15 pub mentions: Vec<Mention>,
16 pub shown: Shown,
17}
18
19#[derive(Clone, Debug)]
21pub struct Mention {
22 pub url: String,
23 pub range: Range<usize>,
25 pub icon: Range<usize>,
27 pub favicon: Option<SharedString>,
28 pub glyph: Option<ui::icons::Icon>,
30 pub initial: SharedString,
32}
33
34pub use ui::input::Shown;
35
36const ICON_SLOT: &str = "\u{2003}";
39
40pub fn flatten(text: &Text, base_weight: FontWeight, theme: &Theme) -> Flat {
43 flatten_with(text, base_weight, theme, |_| None, |_| None)
44}
45
46pub fn flatten_with(
50 text: &Text,
51 base_weight: FontWeight,
52 theme: &Theme,
53 paint: impl Fn(&str) -> Option<crate::MarkPaint>,
54 preview: impl Fn(&str) -> Option<preview::Preview>,
55) -> Flat {
56 let mut cuts: Vec<usize> = text
57 .marks
58 .iter()
59 .flat_map(|span| [span.range.start, span.range.end])
60 .chain([0, text.text.len()])
61 .filter(|cut| *cut <= text.text.len())
62 .collect();
63 cuts.sort_unstable();
64 cuts.dedup();
65
66 let mut atoms: Vec<(Range<usize>, &str)> = text
68 .marks
69 .iter()
70 .filter_map(|span| match &span.mark {
71 Mark::Mention { url, .. } if !span.range.is_empty() => {
72 Some((span.range.clone(), url.as_str()))
73 }
74 _ => None,
75 })
76 .collect();
77 atoms.sort_by_key(|(range, _)| range.start);
78 atoms.dedup_by(|later, earlier| later.0.start < earlier.0.end);
79
80 let mut shown = String::with_capacity(text.text.len());
81 let mut runs = Vec::new();
82 let mut links: Vec<(Range<usize>, String)> = Vec::new();
83 let mut code: Vec<Range<usize>> = Vec::new();
84 let mut mentions: Vec<Mention> = Vec::new();
85 let mut map: Vec<(Range<usize>, Range<usize>)> = Vec::new();
86
87 for pair in cuts.windows(2) {
88 let (start, end) = (pair[0], pair[1]);
89 let atom = atoms
90 .iter()
91 .find(|(range, _)| range.start <= start && end <= range.end);
92 if atom.is_some_and(|(range, _)| range.start != start) {
93 continue;
94 }
95 let covering = text
96 .marks
97 .iter()
98 .filter(|span| span.range.start <= start && span.range.end >= end);
99
100 let (mut bold, mut italic, mut mono, mut strike) = (false, false, false, false);
101 let mut link = None;
102 let mut custom = crate::MarkPaint::default();
105 for span in covering {
106 match &span.mark {
107 Mark::Bold => bold = true,
108 Mark::Italic => italic = true,
109 Mark::Strike => strike = true,
110 Mark::Code => mono = true,
111 Mark::Mention { .. } => {}
112 Mark::Link(url) | Mark::Image(url) => link = Some(url.clone()),
113 Mark::Custom(name) => {
114 let Some(painted) = paint(name) else { continue };
115 custom.color = painted.color.or(custom.color);
116 custom.background = painted.background.or(custom.background);
117 custom.weight = painted.weight.or(custom.weight);
118 custom.italic |= painted.italic;
119 custom.underline |= painted.underline;
120 custom.strikethrough |= painted.strikethrough;
121 }
122 }
123 }
124 let (italic, strike) = (italic || custom.italic, strike || custom.strikethrough);
125
126 let mut face = font(if mono && atom.is_none() {
127 theme.font_mono.clone()
128 } else {
129 theme.font_body.clone()
130 });
131 face.weight = if bold && base_weight.0 < FontWeight::SEMIBOLD.0 {
132 FontWeight::SEMIBOLD
133 } else {
134 custom.weight.unwrap_or(base_weight)
135 };
136 face.style = if italic {
137 FontStyle::Italic
138 } else {
139 FontStyle::Normal
140 };
141 let underline = UnderlineStyle {
142 color: Some(theme.text_muted),
143 thickness: px(1.0),
144 wavy: false,
145 };
146
147 if let Some((range, url)) = atom {
148 let described = preview(url).unwrap_or_default();
149 let host = preview::host(url);
150 let own = &text.text[range.clone()];
151 let title: SharedString = match &described.title {
152 Some(title) => title.clone(),
153 None if own != *url => own.to_string().into(),
154 None => host.to_string().into(),
155 };
156 let from = shown.len();
157 let mut run = |shown: &mut String,
158 piece: &str,
159 color: Hsla,
160 underline: Option<UnderlineStyle>| {
161 shown.push_str(piece);
162 runs.push(TextRun {
163 len: piece.len(),
164 font: face.clone(),
165 color,
166 background_color: None,
167 underline,
168 strikethrough: None,
169 });
170 };
171 run(&mut shown, ICON_SLOT, gpui::transparent_black(), None);
172 run(&mut shown, " ", theme.text, None);
173 if let Some(label) = described.label.filter(|_| described.title.is_some()) {
174 run(&mut shown, &label, theme.text_muted, None);
175 run(&mut shown, " ", theme.text, None);
176 }
177 run(&mut shown, &title, theme.text, Some(underline));
178 let to = shown.len();
179 links.push((from..to, url.to_string()));
180 mentions.push(Mention {
181 url: url.to_string(),
182 range: from..to,
183 icon: from..from + ICON_SLOT.len(),
184 favicon: described.icon,
185 glyph: described.glyph,
186 initial: host
187 .chars()
188 .next()
189 .unwrap_or('?')
190 .to_uppercase()
191 .to_string()
192 .into(),
193 });
194 map.push((range.clone(), from..to));
195 continue;
196 }
197
198 let (from, to) = (shown.len(), shown.len() + end - start);
199 shown.push_str(&text.text[start..end]);
200 if mono {
201 match code.last_mut() {
202 Some(range) if range.end == from => range.end = to,
203 _ => code.push(from..to),
204 }
205 }
206 if let Some(url) = &link {
207 match links.last_mut() {
208 Some((range, last)) if range.end == from && last == url => range.end = to,
209 _ => links.push((from..to, url.clone())),
210 }
211 }
212
213 runs.push(TextRun {
214 len: end - start,
215 font: face,
216 color: match (mono, custom.color) {
219 (_, Some(color)) => color,
220 (true, None) => theme.code_text,
221 (false, None) => theme.text,
222 },
223 background_color: custom.background,
224 underline: (link.is_some() || custom.underline).then_some(underline),
225 strikethrough: strike.then_some(StrikethroughStyle {
226 thickness: px(1.0),
227 color: Some(theme.text_muted),
228 }),
229 });
230 }
231
232 Flat {
233 text: shown.into(),
234 runs,
235 links,
236 code,
237 mentions,
238 shown: Shown::new(map),
239 }
240}
241
242pub(super) fn text_element(
243 text: &Text,
244 size: f32,
245 line_height: f32,
246 weight: FontWeight,
247 overlay: Overlay,
248 theme: &Theme,
249 cx: &App,
250) -> AnyElement {
251 let flat = flatten_with(
252 text,
253 weight,
254 theme,
255 |name| crate::marks::paint_of(cx, name, theme),
256 |url| preview::of(cx, url),
257 );
258 painted_text(flat, text.text.len(), size, line_height, overlay, theme)
259}
260
261pub(super) fn painted_text(
267 flat: Flat,
268 len: usize,
269 size: f32,
270 line_height: f32,
271 overlay: Overlay,
272 theme: &Theme,
273) -> AnyElement {
274 let (ix, part) = (overlay.block, overlay.part);
275 let shown = flat.shown.clone();
276 let caret = overlay.caret_painted().map(|offset| shown.at(offset));
277 let affinity = overlay.affinity();
278 let selected = overlay.selected(len).map(|range| shown.range(&range));
279 let span = 0..len;
280 let hint = overlay
283 .placeholder
284 .filter(|_| len == 0 && overlay.caret().is_some())
287 .map(|hint| {
288 div()
289 .absolute()
290 .text_color(theme.text_faint)
291 .child(hint.clone())
292 });
293 let (shape, height, hollow) = (
294 overlay.caret_shape,
295 overlay.caret_height,
296 overlay.caret_hollow(),
297 );
298 let glyph = caret
299 .filter(|_| shape.cuts_out(hollow))
300 .and_then(|offset| overlay.covered(&flat.text, offset));
301 let runs = match &glyph {
302 Some(glyph) => ui::input::caret::recoloured(flat.runs, glyph, theme.bg),
303 None => flat.runs,
304 };
305 let styled = StyledText::new(flat.text).with_runs(runs);
306 let layout = styled.layout().clone();
307
308 let mentions = flat.mentions;
309 let painted: AnyElement = if flat.links.is_empty() {
310 styled.into_any_element()
311 } else {
312 let hovered: Vec<(Range<usize>, String, bool)> = flat
315 .links
316 .iter()
317 .map(|(range, url)| {
318 let mention = mentions.iter().any(|mention| mention.range == *range);
319 (range.clone(), url.clone(), mention)
320 })
321 .collect();
322 let (ranges, urls): (Vec<_>, Vec<_>) = flat.links.into_iter().unzip();
323 let jump = overlay.jump.cloned();
324 let base = overlay.base.map(Path::to_path_buf);
325 InteractiveText::new(ElementId::named_usize("md-text", ix), styled)
326 .on_click(ranges, move |clicked, window, cx| {
327 if let Some(url) = urls.get(clicked) {
328 crate::link::follow(url, base.as_deref(), jump.as_ref(), window, cx);
329 }
330 })
331 .tooltip(move |at, _window, cx| {
332 let (_, url, mention) = hovered.iter().find(|(range, _, _)| range.contains(&at))?;
333 let owned =
334 || crate::preview::of(cx, url).is_some_and(|preview| preview.glyph.is_some());
335 (*mention || owned()).then(|| MentionCard::view(url, cx))
336 })
337 .into_any_element()
338 };
339
340 let wash = theme.code_wash;
344 let code_ranges = flat.code;
345 let (icon_color, icon_wash) = (theme.text_muted, theme.element_hover);
346 let caret_color = theme.caret;
347 let selection_color = theme.selection;
348 let annotated: Vec<_> = overlay
349 .annotated(len, theme)
350 .into_iter()
351 .map(|(range, wash)| (shown.range(&range), wash))
352 .collect();
353 let layouts = overlay.layouts.cloned();
354 let underlay = canvas(
355 {
356 let mentions = mentions.clone();
357 move |_, window, cx| {
358 mentions
359 .iter()
360 .map(|mention| {
361 let favicon = mention.favicon.clone()?;
362 let source = gpui::Resource::Uri(favicon.to_string().into());
363 window
364 .use_asset::<gpui::ImgResourceLoader>(&source, cx)?
365 .ok()
366 })
367 .collect::<Vec<_>>()
368 }
369 },
370 move |_, favicons, window, cx| {
371 if let Some(layouts) = &layouts {
372 layouts.record(ix, part, span.clone(), layout.clone(), shown.clone());
373 }
374 for (range, wash) in &annotated {
377 for rect in range_rects(&layout, range, 0.0, 0.0) {
378 window.paint_quad(quad(
379 rect,
380 px(2.0),
381 *wash,
382 px(0.0),
383 gpui::transparent_black(),
384 BorderStyle::default(),
385 ));
386 }
387 }
388 if let Some(range) = &selected {
392 for rect in range_rects(&layout, range, 0.0, 0.0) {
393 window.paint_quad(quad(
394 rect,
395 px(2.0),
396 selection_color,
397 px(0.0),
398 gpui::transparent_black(),
399 BorderStyle::default(),
400 ));
401 }
402 }
403 if let Some(offset) = caret {
404 let face = font(Theme::of(cx).font_body.clone());
405 paint_caret(
406 &layout,
407 offset,
408 affinity,
409 glyph.as_ref(),
410 CaretPaint {
411 shape,
412 height,
413 hollow,
414 color: caret_color,
415 size,
416 face,
417 },
418 window,
419 );
420 }
421 for range in &code_ranges {
422 for rect in range_rects(&layout, range, INLINE_CODE_PAD_X, INLINE_CODE_INSET_Y) {
423 window.paint_quad(quad(
424 rect,
425 px(INLINE_CODE_RADIUS),
426 wash,
427 px(0.0),
428 gpui::transparent_black(),
429 BorderStyle::default(),
430 ));
431 }
432 }
433 for (mention, favicon) in mentions.iter().zip(favicons) {
434 let Some(slot) = range_rects(&layout, &mention.icon, 0.0, 0.0).pop() else {
435 continue;
436 };
437 let side = slot.size.width.min(px(size));
438 let icon = Bounds::new(
439 slot.origin + point(px(0.0), (slot.size.height - side) / 2.0),
440 gpui::size(side, side),
441 );
442 let radius = gpui::Corners::all(side / 4.0);
443 if let Some(data) = mention.glyph.as_ref().and_then(|glyph| glyph.data()) {
444 let path = SharedString::from(format!("markdown-glyph-{:p}", data.as_ptr()));
445 window
446 .paint_svg(
447 icon,
448 path,
449 Some(data),
450 gpui::TransformationMatrix::unit(),
451 icon_color,
452 cx,
453 )
454 .ok();
455 continue;
456 }
457 if let Some(favicon) = favicon {
458 window
459 .paint_image(icon, icon, radius, favicon, 0, false)
460 .ok();
461 continue;
462 }
463 window.paint_quad(quad(
464 icon,
465 side / 4.0,
466 icon_wash,
467 px(0.0),
468 gpui::transparent_black(),
469 BorderStyle::default(),
470 ));
471 let font = font(Theme::of(cx).font_body.clone());
472 let letter = window.text_system().shape_line(
473 mention.initial.clone(),
474 side * 0.55,
475 &[TextRun {
476 len: mention.initial.len(),
477 font,
478 color: icon_color,
479 background_color: None,
480 underline: None,
481 strikethrough: None,
482 }],
483 None,
484 );
485 let at = point(icon.center().x - letter.width / 2.0, icon.origin.y);
486 letter
487 .paint(at, side, gpui::TextAlign::Left, None, window, cx)
488 .ok();
489 }
490 },
491 )
492 .absolute()
493 .size_full();
494
495 div()
496 .text_size(px(size))
497 .line_height(px(line_height))
498 .relative()
499 .child(underlay)
500 .children(hint)
501 .child(painted)
502 .into_any_element()
503}
504
505pub(super) fn caret_quad(head: Point<Pixels>, size: f32, line_height: Pixels) -> Bounds<Pixels> {
511 let inset = (line_height - px(size)) / 2.0;
512 Bounds::new(
513 head + point(px(0.0), inset),
514 gpui::size(px(CARET_WIDTH), px(size)),
515 )
516}
517
518pub(super) fn glyph_at(text: &str, offset: usize) -> Option<Range<usize>> {
520 (offset < text.len() && text.is_char_boundary(offset) && !text[offset..].starts_with('\n'))
521 .then(|| offset..ui::input::next_boundary(text, offset))
522}
523
524pub(super) struct CaretPaint {
526 pub shape: ui::input::CaretShape,
527 pub height: ui::input::CaretHeight,
528 pub hollow: bool,
529 pub color: Hsla,
530 pub size: f32,
532 pub face: gpui::Font,
534}
535
536pub(super) fn caret_position(
539 layout: &TextLayout,
540 offset: usize,
541 affinity: Affinity,
542) -> Option<Point<Pixels>> {
543 if affinity == Affinity::Downstream
544 && let Some(start) = wrapped_row_start(layout, offset)
545 {
546 return Some(start);
547 }
548 layout.position_for_index(offset)
549}
550
551fn wrapped_row_start(layout: &TextLayout, offset: usize) -> Option<Point<Pixels>> {
554 let line_height = layout.line_height();
555 let mut origin = layout.bounds().origin;
556 let mut line_start = 0;
557 for line in layout.line_layouts() {
558 if offset > line_start + line.len() {
559 origin.y += line.size(line_height).height;
560 line_start += line.len() + 1;
561 continue;
562 }
563 let shaped = &line.unwrapped_layout;
564 let row = line.wrap_boundaries().iter().position(|wrap| {
565 line_start + shaped.runs[wrap.run_ix].glyphs[wrap.glyph_ix].index == offset
566 })?;
567 return Some(origin + point(px(0.0), line_height * (row + 1) as f32));
568 }
569 None
570}
571
572pub(super) fn paint_caret(
575 layout: &TextLayout,
576 offset: usize,
577 affinity: Affinity,
578 glyph: Option<&Range<usize>>,
579 paint: CaretPaint,
580 window: &mut Window,
581) {
582 let line_height = layout.line_height();
583 let covered = glyph.and_then(|glyph| range_rects(layout, glyph, 0.0, 0.0).into_iter().next());
584 let (head, width) = match covered {
585 Some(rect) => (rect.origin, rect.size.width),
586 None => {
587 let Some(head) = caret_position(layout, offset, affinity) else {
588 return;
589 };
590 let width = match paint.shape {
591 ui::input::CaretShape::Bar => px(0.0),
592 _ => caret_advance(layout, offset).unwrap_or_else(|| {
593 ui::input::caret::zero_width(paint.face, px(paint.size), window)
594 }),
595 };
596 (head, width)
597 }
598 };
599 window.paint_quad(paint.shape.quad(
600 caret_quad(head, paint.size, line_height),
601 line_height,
602 paint.height,
603 width,
604 paint.color,
605 paint.hollow,
606 ));
607}
608
609pub(super) fn range_rects(
611 layout: &gpui::TextLayout,
612 range: &Range<usize>,
613 pad_x: f32,
614 inset_y: f32,
615) -> Vec<Bounds<Pixels>> {
616 let mut rects = Vec::new();
617 let line_height = layout.line_height();
618 let mut origin = layout.bounds().origin;
619 let mut line_start = 0;
620 for line in layout.line_layouts() {
621 let shaped = &line.unwrapped_layout;
622 let row_ends = line
625 .wrap_boundaries()
626 .iter()
627 .map(|wrap| shaped.runs[wrap.run_ix].glyphs[wrap.glyph_ix].index)
628 .chain([line.len()]);
629 let mut row_start = 0;
630 for (row, row_end) in row_ends.enumerate() {
631 let from = range
632 .start
633 .saturating_sub(line_start)
634 .clamp(row_start, row_end);
635 let to = range.end.saturating_sub(line_start).min(row_end);
636 let row_x = shaped.x_for_index(row_start);
637 let (left, right) = (shaped.x_for_index(from), shaped.x_for_index(to));
638 if from < to && right > left {
639 rects.push(Bounds::new(
640 origin
641 + point(
642 left - row_x - px(pad_x),
643 line_height * row as f32 + px(inset_y),
644 ),
645 size(
646 right - left + px(2.0 * pad_x),
647 line_height - px(2.0 * inset_y),
648 ),
649 ));
650 }
651 row_start = row_end;
652 }
653 origin.y += line.size(line_height).height;
654 line_start += line.len() + 1;
656 }
657 rects
658}
659
660pub(super) fn caret_advance(layout: &TextLayout, offset: usize) -> Option<Pixels> {
662 let text = layout.text();
663 let mut start = 0;
664 for line in layout.line_layouts() {
665 let end = start + line.len();
666 if offset <= end {
667 return ui::input::caret::character_advance(&line, &text[start..end], offset - start);
668 }
669 start = end + 1;
670 }
671 None
672}