oxicode_vtui/tui/ui/markdown/
mod.rs1use anstyle::{Color as AnsiColorEnum, Effects, RgbColor};
3use oxicode_vtui_compat::ui_protocol::{InlineSegment, InlineTextStyle};
4use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
5use std::sync::Arc;
6use syntect::easy::HighlightLines;
7use syntect::parsing::SyntaxSet;
8
9pub fn render_markdown(text: &str) -> Vec<Vec<InlineSegment>> {
11 let mut opts = Options::empty();
12 opts.insert(Options::ENABLE_TABLES);
13 opts.insert(Options::ENABLE_STRIKETHROUGH);
14 opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
15
16 let mut lines: Vec<Vec<InlineSegment>> = Vec::new();
17 let mut cur: Vec<InlineSegment> = Vec::new();
18 let effects: Effects = Effects::default();
19
20 let mut code_buf: Option<CodeBlockState> = None;
21 let ss = SyntaxSet::load_defaults_newlines();
22
23 for event in Parser::new_ext(text, opts) {
24 if let Some(ref mut cb) = code_buf {
26 match event {
27 Event::Text(t) => cb.code.push_str(&t),
28 Event::End(TagEnd::CodeBlock) => {
29 let cb = code_buf.take().unwrap();
30 flush_line(&mut cur, &mut lines);
31 let block_lines = render_code_block(&cb.code, cb.lang.as_deref(), &ss);
32 lines.extend(block_lines);
33 lines.push(Vec::new());
34 }
35 _ => {}
36 }
37 continue;
38 }
39
40 match event {
42 Event::Start(Tag::CodeBlock(kind)) => {
44 flush_line(&mut cur, &mut lines);
45 let lang = match kind {
46 CodeBlockKind::Fenced(l) => Some(l.to_string()),
47 CodeBlockKind::Indented => None,
48 };
49 code_buf = Some(CodeBlockState {
50 code: String::new(),
51 lang,
52 });
53 }
54
55 Event::Start(Tag::Paragraph) => {}
57
58 Event::Start(Tag::Heading { level, .. }) => {
59 flush_line(&mut cur, &mut lines);
60 {
61 let _ = effects.insert(Effects::BOLD);
62 };
63 {
64 let _ = effects.insert(if level == HeadingLevel::H1 {
65 Effects::UNDERLINE
66 } else {
67 Effects::default()
68 });
69 }
70 }
71 Event::End(TagEnd::Heading(_)) => {
72 {
73 let _ = effects.remove(Effects::BOLD | Effects::UNDERLINE);
74 };
75 flush_line(&mut cur, &mut lines);
76 }
77
78 Event::Start(Tag::BlockQuote(_)) => {
79 {
80 let _ = effects.insert(Effects::DIMMED);
81 };
82 }
83 Event::End(TagEnd::BlockQuote(_)) => {
84 {
85 let _ = effects.remove(Effects::DIMMED);
86 };
87 }
88
89 Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Item) => {
90 flush_line(&mut cur, &mut lines);
91 }
92 Event::End(TagEnd::List(_)) => {
93 lines.push(Vec::new());
94 }
95
96 Event::Rule => {
97 flush_line(&mut cur, &mut lines);
98 lines.push(vec![InlineSegment {
99 text: "\u{2500}".repeat(40),
100 style: Arc::new(InlineTextStyle::default().dim()),
101 }]);
102 }
103
104 Event::Start(Tag::Emphasis) => {
106 {
107 let _ = effects.insert(Effects::ITALIC);
108 };
109 }
110 Event::End(TagEnd::Emphasis) => {
111 {
112 let _ = effects.remove(Effects::ITALIC);
113 };
114 }
115
116 Event::Start(Tag::Strong) => {
117 {
118 let _ = effects.insert(Effects::BOLD);
119 };
120 }
121 Event::End(TagEnd::Strong) => {
122 {
123 let _ = effects.remove(Effects::BOLD);
124 };
125 }
126
127 Event::Start(Tag::Strikethrough) => {
128 {
129 let _ = effects.insert(Effects::STRIKETHROUGH);
130 };
131 }
132 Event::End(TagEnd::Strikethrough) => {
133 {
134 let _ = effects.remove(Effects::STRIKETHROUGH);
135 };
136 }
137
138 Event::Start(Tag::Link { .. }) => {
139 {
140 let _ = effects.insert(Effects::UNDERLINE);
141 };
142 }
143 Event::End(TagEnd::Link) => {
144 {
145 let _ = effects.remove(Effects::UNDERLINE);
146 };
147 }
148
149 Event::Text(t) | Event::Html(t) => {
151 let style = apply_effects(InlineTextStyle::default(), effects);
152 let seg = InlineSegment {
153 text: t.to_string(),
154 style: Arc::new(style),
155 };
156 merge_or_push(&mut cur, seg);
157 }
158
159 Event::Code(t) => {
160 let seg = InlineSegment {
161 text: t.to_string(),
162 style: Arc::new(InlineTextStyle::default().bold()),
163 };
164 merge_or_push(&mut cur, seg);
165 }
166
167 Event::SoftBreak | Event::HardBreak => {
168 flush_line(&mut cur, &mut lines);
169 }
170
171 Event::FootnoteReference(t) => {
172 let seg = InlineSegment {
173 text: format!("[^{}]", t),
174 style: Arc::new(InlineTextStyle::default().dim()),
175 };
176 merge_or_push(&mut cur, seg);
177 }
178
179 _ => {}
180 }
181 }
182
183 flush_line(&mut cur, &mut lines);
184 lines
185}
186
187pub fn render_code_block(
189 code: &str,
190 lang: Option<&str>,
191 ss: &SyntaxSet,
192) -> Vec<Vec<InlineSegment>> {
193 let syntax = lang
194 .and_then(|l| ss.find_syntax_by_token(l))
195 .unwrap_or_else(|| ss.find_syntax_plain_text());
196 let theme = syntect::highlighting::Theme::default();
197 #[allow(unused_mut)]
198 let mut h = HighlightLines::new(syntax, &theme);
199 let mut lines = Vec::new();
200
201 for line in syntect::util::LinesWithEndings::from(code) {
202 if let Ok(ranges) = h.highlight_line(line, ss) {
203 let segs: Vec<InlineSegment> = ranges
204 .into_iter()
205 .map(|(s, t)| {
206 let fg = s.foreground;
207 InlineSegment {
208 text: t.to_string(),
209 style: Arc::new(
210 InlineTextStyle::default()
211 .with_color(Some(AnsiColorEnum::Rgb(RgbColor(fg.r, fg.g, fg.b)))),
212 ),
213 }
214 })
215 .collect();
216 lines.push(segs);
217 } else {
218 lines.push(vec![InlineSegment {
219 text: line.to_string(),
220 style: Arc::new(InlineTextStyle::default()),
221 }]);
222 }
223 }
224 lines
225}
226
227struct CodeBlockState {
230 code: String,
231 lang: Option<String>,
232}
233
234fn flush_line(cur: &mut Vec<InlineSegment>, lines: &mut Vec<Vec<InlineSegment>>) {
235 if !cur.is_empty() {
236 lines.push(std::mem::take(cur));
237 }
238}
239
240fn merge_or_push(cur: &mut Vec<InlineSegment>, seg: InlineSegment) {
241 if let Some(last) = cur.last_mut() {
242 if last.style == seg.style {
243 last.text.push_str(&seg.text);
244 return;
245 }
246 }
247 cur.push(seg);
248}
249
250fn apply_effects(mut style: InlineTextStyle, effects: Effects) -> InlineTextStyle {
251 if effects.contains(Effects::BOLD) {
252 style = style.bold();
253 }
254 if effects.contains(Effects::ITALIC) {
255 style = style.italic();
256 }
257 if effects.contains(Effects::UNDERLINE) {
258 style = style.underline();
259 }
260 if effects.contains(Effects::DIMMED) {
261 style = style.dim();
262 }
263 if effects.contains(Effects::STRIKETHROUGH) {
264 style.effects |= Effects::STRIKETHROUGH;
265 }
266 style
267}