1use std::fmt;
4use std::rc::Rc;
5
6use ravenrs_core::{Color, CursorIcon, Insets, Quad, Rect, Role, Scene};
7use ravenrs_layout::{Align, Axis, LayoutStyle};
8use ravenrs_style::{Semantic, TextStyle, Weight};
9use ravenrs_text::TextAttrs;
10
11use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
12use crate::{Fill, Text, View};
13
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16struct Marks {
17 bold: bool,
18 italic: bool,
19 strike: bool,
20 code: bool,
21}
22
23#[cfg_attr(feature = "live", ravenrs_live::expose)]
24impl Marks {
25 fn with(self, mark: char) -> Self {
26 match mark {
27 '*' => Self { bold: true, ..self },
28 '_' => Self { italic: true, ..self },
29 '~' => Self { strike: true, ..self },
30 _ => Self { code: true, ..self },
31 }
32 }
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37struct Span {
38 text: String,
39 marks: Marks,
40 link: bool,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45enum LineKind {
46 Plain,
47 Item,
48 Quote,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
53enum Block {
54 Line(LineKind, Vec<Span>),
55 Code(String),
56}
57
58#[cfg_attr(feature = "live", ravenrs_live::expose)]
59impl Block {
60 fn is_plain(&self) -> bool {
61 match self {
62 Self::Line(LineKind::Plain, spans) => {
63 spans.iter().all(|span| span.marks == Marks::default() && !span.link)
64 }
65 _ => false,
66 }
67 }
68}
69
70const FENCE: &str = "```";
71
72fn parse(text: &str, marks: bool) -> Vec<Block> {
73 let mut blocks = Vec::new();
74 let mut rest = text;
75 if marks {
76 while let Some(open) = rest.find(FENCE) {
78 let inside = &rest[open + FENCE.len()..];
79 let Some(close) = inside.find(FENCE) else { break };
80 let code = inside[..close].trim_matches('\n');
81 if code.trim().is_empty() {
82 let end = open + FENCE.len() * 2 + close;
84 parse_lines(&rest[..end], marks, &mut blocks);
85 rest = &rest[end..];
86 continue;
87 }
88 parse_lines(rest[..open].strip_suffix('\n').unwrap_or(&rest[..open]), marks, &mut blocks);
89 blocks.push(Block::Code(code.to_owned()));
90 let after = &inside[close + FENCE.len()..];
91 rest = after.strip_prefix('\n').unwrap_or(after);
92 if rest.is_empty() {
93 return blocks;
94 }
95 }
96 }
97 parse_lines(rest, marks, &mut blocks);
98 blocks
99}
100
101fn parse_lines(text: &str, marks: bool, blocks: &mut Vec<Block>) {
102 if text.is_empty() {
103 return;
104 }
105 for line in text.split('\n') {
106 let filled = |body: &&str| !body.trim().is_empty();
107 let (kind, body) = if !marks {
108 (LineKind::Plain, line)
109 } else if let Some(body) = line.strip_prefix("* ").or_else(|| line.strip_prefix("- ")).filter(filled)
110 {
111 (LineKind::Item, body)
112 } else if let Some(body) = line.strip_prefix("> ").filter(filled) {
113 (LineKind::Quote, body)
114 } else {
115 (LineKind::Plain, line)
116 };
117 let mut spans = Vec::new();
118 parse_inline(body, Marks::default(), marks, &mut spans);
119 blocks.push(Block::Line(kind, spans));
120 }
121}
122
123fn link_length(text: &str) -> Option<usize> {
127 let scheme = ["https://", "http://"].into_iter().find(|scheme| text.starts_with(scheme))?;
128 let end =
129 text.find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"' | '\'')).unwrap_or(text.len());
130 let link = text[..end].trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']', '}']);
131 (link.len() > scheme.len()).then_some(link.len())
132}
133
134fn opens(text: &str, at: usize, mark: char) -> bool {
136 let before = text[..at].chars().next_back();
137 let after = text[at + mark.len_utf8()..].chars().next();
138 before.is_none_or(|c| !c.is_alphanumeric() && c != mark)
139 && after.is_some_and(|c| !c.is_whitespace() && c != mark)
140}
141
142fn closes(text: &str, at: usize, mark: char) -> Option<usize> {
145 let start = at + mark.len_utf8();
146 text[start..].match_indices(mark).map(|(i, _)| start + i).find(|&i| {
147 if i == start {
148 return false;
149 }
150 if mark == '`' {
151 return true;
152 }
153 let before = text[..i].chars().next_back();
154 let after = text[i + mark.len_utf8()..].chars().next();
155 before.is_some_and(|c| !c.is_whitespace()) && after.is_none_or(|c| !c.is_alphanumeric())
156 })
157}
158
159fn push_span(spans: &mut Vec<Span>, text: &str, marks: Marks, link: bool) {
160 if !text.is_empty() {
161 spans.push(Span { text: text.to_owned(), marks, link });
162 }
163}
164
165fn parse_inline(text: &str, base: Marks, marks: bool, spans: &mut Vec<Span>) {
166 let (mut pending, mut at) = (0, 0);
167 while let Some(c) = text[at..].chars().next() {
168 if c == 'h'
170 && let Some(length) = link_length(&text[at..])
171 {
172 push_span(spans, &text[pending..at], base, false);
173 push_span(spans, &text[at..at + length], base, true);
174 at += length;
175 pending = at;
176 continue;
177 }
178 if marks
179 && matches!(c, '*' | '_' | '~' | '`')
180 && opens(text, at, c)
181 && let Some(close) = closes(text, at, c)
182 {
183 push_span(spans, &text[pending..at], base, false);
184 let inside = &text[at + 1..close];
185 if c == '`' {
186 push_span(spans, inside, base.with(c), false);
187 } else {
188 parse_inline(inside, base.with(c), true, spans);
189 }
190 at = close + 1;
191 pending = at;
192 continue;
193 }
194 at += c.len_utf8();
195 }
196 push_span(spans, &text[pending..], base, false);
197}
198
199fn read_aloud(blocks: &[Block]) -> String {
201 let lines: Vec<String> = blocks
202 .iter()
203 .map(|block| match block {
204 Block::Line(_, spans) => spans.iter().map(|span| span.text.as_str()).collect(),
205 Block::Code(code) => code.clone(),
206 })
207 .collect();
208 lines.join("\n")
209}
210
211#[derive(Clone)]
233#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
234pub struct RichText {
235 text: String,
236 marks: bool,
237 style: TextStyle,
238 foreground: Fill,
239 on_link: Option<Rc<dyn Fn(String)>>,
240}
241
242impl fmt::Debug for RichText {
243 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244 f.debug_struct("RichText")
245 .field("text", &self.text)
246 .field("style", &self.style)
247 .finish_non_exhaustive()
248 }
249}
250
251#[cfg_attr(feature = "live", ravenrs_live::expose)]
252impl RichText {
253 pub fn new(text: impl Into<String>) -> Self {
255 Self {
256 text: text.into(),
257 marks: true,
258 style: TextStyle::Body,
259 foreground: Semantic::Ink.into(),
260 on_link: None,
261 }
262 }
263
264 #[must_use]
267 pub fn links_only(mut self) -> Self {
268 self.marks = false;
269 self
270 }
271
272 #[must_use]
274 pub fn style(mut self, style: TextStyle) -> Self {
275 self.style = style;
276 self
277 }
278
279 #[must_use]
281 pub fn foreground(mut self, fill: impl Into<Fill>) -> Self {
282 self.foreground = fill.into();
283 self
284 }
285
286 #[must_use]
289 pub fn on_link(mut self, handler: impl Fn(String) + 'static) -> Self {
290 self.on_link = Some(Rc::new(handler));
291 self
292 }
293
294 pub fn plain_text(text: &str) -> String {
297 read_aloud(&parse(text, true)).split_whitespace().collect::<Vec<_>>().join(" ")
298 }
299}
300
301struct Piece {
303 text: String,
304 marks: Marks,
305 link: bool,
306 strike_on: bool,
308}
309
310fn words(spans: &[Span]) -> Vec<Vec<Piece>> {
313 let mut words: Vec<Vec<Piece>> = Vec::new();
314 let mut glued = false;
315 for span in spans {
316 let piece = |text: &str, strike_on| Piece {
317 text: text.to_owned(),
318 marks: span.marks,
319 link: span.link,
320 strike_on,
321 };
322 if span.link || span.marks.code {
323 if glued && let Some(word) = words.last_mut() {
324 word.push(piece(&span.text, false));
325 } else {
326 words.push(vec![piece(&span.text, false)]);
327 }
328 glued = true;
329 continue;
330 }
331 let parts: Vec<&str> = span.text.split_whitespace().collect();
332 if parts.is_empty() {
333 glued = false;
334 continue;
335 }
336 let spaced_before = span.text.starts_with(char::is_whitespace);
337 for (i, part) in parts.iter().enumerate() {
338 let strike_on = span.marks.strike && i + 1 < parts.len();
339 if i == 0
340 && glued
341 && !spaced_before
342 && let Some(word) = words.last_mut()
343 {
344 word.push(piece(part, strike_on));
345 } else {
346 words.push(vec![piece(part, strike_on)]);
347 }
348 }
349 glued = !span.text.ends_with(char::is_whitespace);
350 }
351 words
352}
353
354fn rule(frame: Rect, y: f32, color: Color, scene: &mut Scene) {
356 scene.push_quad(Quad::new(Rect::new(frame.x(), frame.y() + y, frame.width(), 1.0), color));
357}
358
359struct LinkPiece {
361 text: String,
362 attrs: TextAttrs,
363 on_link: Option<Rc<dyn Fn(String)>>,
364}
365
366impl View for LinkPiece {
367 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
368 let theme = *cx.theme();
369 let active = self.on_link.is_some();
370 let role = if !active {
371 Semantic::Accent
372 } else if cx.is_pressed() {
373 Semantic::AccentPressed
374 } else if cx.is_hovered() {
375 Semantic::AccentHover
376 } else {
377 Semantic::Accent
378 };
379 let color = theme.color(role).lerp(theme.color(Semantic::Ink), 0.3);
382 let text = cx.text(&self.text, &self.attrs, color, LayoutStyle::leaf());
383 cx.semantics(text, Semantics::hidden());
384 let (words, attrs) = (self.text.clone(), self.attrs.clone());
385 let node = cx.container(
386 LayoutStyle { min_width: Some(0.0), ..LayoutStyle::stack(Axis::Horizontal) },
387 &[text],
388 Paint::Rich(Rc::new(move |frame, paint| {
390 let mut layout = paint.text.layout(&words, &attrs);
391 layout.measure(Some(frame.width() + 0.5));
392 for line in layout.lines() {
393 let row = Rect::new(frame.x(), frame.y() + line.top, line.width, line.height);
394 rule(row, line.height - 3.0, color, paint.scene);
395 }
396 })),
397 );
398 if let Some(on_link) = &self.on_link {
399 let (on_link, address) = (on_link.clone(), self.text.clone());
400 cx.on_click(node, Rc::new(move || on_link(address.clone())));
401 cx.focusable(node, theme.radii.sm);
402 cx.cursor(node, CursorIcon::Pointer);
403 cx.semantics(node, Semantics::new(Role::Link).name(self.text.clone()));
404 }
405 node
406 }
407}
408
409#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
410impl RichText {
411 fn word(
413 &self,
414 cx: &mut BuildCx<'_>,
415 pieces: &[Piece],
416 ink: Color,
417 space: f32,
418 next_link: &mut u64,
419 ) -> NodeId {
420 let theme = *cx.theme();
421 let base = crate::util::attrs(&theme, self.style, None);
422 let mut children = Vec::with_capacity(pieces.len());
423 let mut trailing = space;
424 for piece in pieces {
425 let mut attrs = base.clone();
426 if piece.marks.bold {
427 attrs.weight = Weight::SEMIBOLD.0;
428 }
429 if piece.link {
430 let key = *next_link;
431 *next_link += 1;
432 children.push(cx.keyed_child(
433 key,
434 &LinkPiece { text: piece.text.clone(), attrs, on_link: self.on_link.clone() },
435 ));
436 continue;
437 }
438 let color = if piece.marks.italic { theme.color(Semantic::InkMuted) } else { ink };
439 let text = cx.text(&piece.text, &attrs, color, LayoutStyle::leaf());
440 cx.semantics(text, Semantics::hidden());
441 let node = if piece.marks.code {
442 cx.container(
443 LayoutStyle {
444 padding: Insets::symmetric(0.0, theme.spacing.s1),
445 min_width: Some(0.0),
446 ..LayoutStyle::stack(Axis::Horizontal)
447 },
448 &[text],
449 Paint::Quad(QuadPaint {
450 corner_radius: theme.radii.sm,
451 ..QuadPaint::new(theme.color(Semantic::Border))
452 }),
453 )
454 } else if piece.marks.strike {
455 let middle = base.line_height / 2.0;
456 let reach = if piece.strike_on { space } else { 0.0 };
457 if piece.strike_on {
458 trailing = 0.0;
459 }
460 cx.container(
461 LayoutStyle {
462 padding: Insets { right: reach, ..Insets::ZERO },
463 min_width: Some(0.0),
464 ..LayoutStyle::stack(Axis::Horizontal)
465 },
466 &[text],
467 Paint::Custom(Rc::new(move |frame, scene| rule(frame, middle, color, scene))),
468 )
469 } else {
470 text
471 };
472 children.push(node);
473 }
474 cx.container(
475 LayoutStyle {
476 padding: Insets { right: trailing, ..Insets::ZERO },
477 min_width: Some(0.0),
478 ..LayoutStyle::stack(Axis::Horizontal)
479 },
480 &children,
481 Paint::None,
482 )
483 }
484
485 fn flow(&self, cx: &mut BuildCx<'_>, spans: &[Span], ink: Color, next_link: &mut u64) -> NodeId {
487 let attrs = crate::util::attrs(cx.theme(), self.style, None);
488 let one = cx.measure_text("n", &attrs).width;
490 let space = cx.measure_text("n n", &attrs).width - one * 2.0;
491 let words = words(spans);
492 let children: Vec<NodeId> =
493 words.iter().map(|pieces| self.word(cx, pieces, ink, space, next_link)).collect();
494 cx.container(
495 LayoutStyle {
496 wrap: true,
497 grow: 1.0,
498 min_width: Some(0.0),
499 min_height: Some(attrs.line_height),
501 ..LayoutStyle::stack(Axis::Horizontal)
502 },
503 &children,
504 Paint::None,
505 )
506 }
507}
508
509impl View for RichText {
510 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
511 let blocks = parse(&self.text, self.marks);
512 if blocks.iter().all(Block::is_plain) {
513 return cx.child(&Text::new(self.text.clone()).style(self.style).foreground(self.foreground));
514 }
515 let theme = *cx.theme();
516 let ink = self.foreground.resolve(&theme);
517 let attrs = crate::util::attrs(&theme, self.style, None);
518 let mut next_link = 0;
519 let mut children = Vec::with_capacity(blocks.len());
520 for block in &blocks {
521 let node = match block {
522 Block::Line(LineKind::Plain, spans) => self.flow(cx, spans, ink, &mut next_link),
523 Block::Line(LineKind::Item, spans) => {
524 let bullet = cx.text("•", &attrs, ink, LayoutStyle::leaf());
525 cx.semantics(bullet, Semantics::hidden());
526 let line = self.flow(cx, spans, ink, &mut next_link);
527 cx.container(
528 LayoutStyle {
529 spacing: theme.spacing.s2,
530 padding: Insets { left: theme.spacing.s1, ..Insets::ZERO },
531 ..LayoutStyle::stack(Axis::Horizontal)
532 },
533 &[bullet, line],
534 Paint::None,
535 )
536 }
537 Block::Line(LineKind::Quote, spans) => {
538 let bar = cx.container(
539 LayoutStyle {
540 width: Some(2.0),
541 align_self: Some(Align::Stretch),
542 ..LayoutStyle::leaf()
543 },
544 &[],
545 Paint::Quad(QuadPaint {
546 corner_radius: 1.0,
547 ..QuadPaint::new(theme.color(Semantic::Border))
548 }),
549 );
550 let line = self.flow(cx, spans, theme.color(Semantic::InkMuted), &mut next_link);
551 cx.container(
552 LayoutStyle { spacing: theme.spacing.s2, ..LayoutStyle::stack(Axis::Horizontal) },
553 &[bar, line],
554 Paint::None,
555 )
556 }
557 Block::Code(code) => {
558 let text = cx.text(code, &attrs, ink, LayoutStyle::leaf());
559 cx.semantics(text, Semantics::hidden());
560 cx.container(
561 LayoutStyle {
562 padding: Insets::symmetric(theme.spacing.s2, theme.spacing.s3),
563 align_self: Some(Align::Stretch),
564 ..LayoutStyle::stack(Axis::Vertical)
565 },
566 &[text],
567 Paint::Quad(QuadPaint {
568 corner_radius: theme.radii.md,
569 ..QuadPaint::new(theme.color(Semantic::Border))
570 }),
571 )
572 }
573 };
574 children.push(node);
575 }
576 let node = cx.container(
577 LayoutStyle { align: Align::Stretch, min_width: Some(0.0), ..LayoutStyle::stack(Axis::Vertical) },
578 &children,
579 Paint::None,
580 );
581 cx.semantics(node, Semantics::new(Role::Label).name(read_aloud(&blocks)));
582 node
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 fn line(text: &str) -> Vec<(String, Marks, bool)> {
591 let blocks = parse(text, true);
592 let Block::Line(_, spans) = &blocks[0] else { panic!("a line") };
593 spans.iter().map(|s| (s.text.clone(), s.marks, s.link)).collect()
594 }
595
596 fn marked(text: &str, marks: Marks) -> (String, Marks, bool) {
597 (text.to_owned(), marks, false)
598 }
599
600 const BOLD: Marks = Marks { bold: true, italic: false, strike: false, code: false };
601
602 #[test]
603 fn marks_nest_and_vanish_from_the_text() {
604 let both = Marks { italic: true, ..BOLD };
605 assert_eq!(line("*a _b_ c*"), [marked("a ", BOLD), marked("b", both), marked(" c", BOLD)]);
606 assert_eq!(RichText::plain_text("*a* _b_\n~c~ `d`"), "a b c d");
607 }
608
609 #[test]
610 fn a_mark_must_touch_what_it_marks_and_not_sit_inside_a_word() {
611 for literal in ["2 * 3 * 4", "snake_case_name", "a*b*c", "*not closed", "`` no"] {
612 let blocks = parse(literal, true);
613 assert!(blocks.iter().all(Block::is_plain), "{literal}");
614 assert_eq!(read_aloud(&blocks), literal);
615 }
616 assert_eq!(
617 line("(*yes*),"),
618 [marked("(", Marks::default()), marked("yes", BOLD), marked("),", Marks::default())]
619 );
620 }
621
622 #[test]
623 fn code_keeps_marks_literal_and_a_fence_crosses_lines() {
624 let code = Marks { code: true, ..Marks::default() };
625 assert_eq!(line("`*no*`"), [marked("*no*", code)]);
626 let blocks = parse("before\n```\nlet *a* = 1;\nb\n```\nafter", true);
627 assert_eq!(blocks[1], Block::Code("let *a* = 1;\nb".into()));
628 assert_eq!(blocks.len(), 3);
629 assert_eq!(read_aloud(&parse("``````", true)), "``````");
630 }
631
632 #[test]
633 fn links_are_whole_web_addresses_without_the_end_of_the_sentence() {
634 assert_eq!(
635 line("see https://x.io/a_b_c."),
636 [
637 marked("see ", Marks::default()),
638 ("https://x.io/a_b_c".to_owned(), Marks::default(), true),
639 marked(".", Marks::default()),
640 ]
641 );
642 for not_a_link in ["javascript:alert(1)", "file:///etc/passwd", "https://", "http"] {
643 assert!(parse(not_a_link, true).iter().all(Block::is_plain), "{not_a_link}");
644 }
645 }
646
647 #[test]
648 fn links_only_leaves_the_marks_alone() {
649 let blocks = parse("*a* https://x.io\n* b", false);
650 let Block::Line(LineKind::Plain, spans) = &blocks[0] else { panic!("a plain line") };
651 assert_eq!(spans[0].text, "*a* ");
652 assert!(spans[1].link);
653 assert!(matches!(&blocks[1], Block::Line(LineKind::Plain, _)));
654 }
655
656 #[test]
657 fn a_word_keeps_its_punctuation_and_a_chip_its_spaces() {
658 let blocks = parse("(*a*), `b c` d", true);
659 let Block::Line(_, spans) = &blocks[0] else { panic!("a line") };
660 let words = words(spans);
661 let read: Vec<Vec<&str>> =
662 words.iter().map(|word| word.iter().map(|p| p.text.as_str()).collect()).collect();
663 assert_eq!(read, [vec!["(", "a", "),"], vec!["b c"], vec!["d"]]);
664 }
665}