1use crate::theme::{GLYPH_BROKEN, GLYPH_OK, Theme};
10use okf_core::markdown::parse_heading_line;
11use okf_core::{Link, LinkKind};
12use okf_validator::{Language, check_syntax};
13use ratatui::style::{Modifier, Style};
14use ratatui::text::{Line, Span};
15use std::collections::HashMap;
16
17#[derive(Clone, Debug)]
19pub enum FocusKind {
20 Link {
22 text: String,
24 target: String,
26 kind: LinkKind,
28 },
29 Footnote(String),
31}
32
33#[derive(Clone, Debug)]
35pub struct FocusTarget {
36 pub line: usize,
38 pub kind: FocusKind,
40}
41
42#[derive(Clone, Debug)]
44pub struct HeadingPos {
45 pub line: usize,
47 pub level: usize,
49 pub text: String,
51}
52
53#[derive(Clone, Debug, Default)]
55pub struct RenderedDoc {
56 pub lines: Vec<Line<'static>>,
58 pub links: Vec<FocusTarget>,
60 pub headings: Vec<HeadingPos>,
62 pub footnote_defs: HashMap<String, usize>,
65}
66
67#[must_use]
69pub const fn char_width(c: char) -> usize {
70 let cp = c as u32;
71 if c.is_control() {
72 return 0;
73 }
74 let wide = matches!(
75 cp,
76 0x1100..=0x115F
77 | 0x2E80..=0xA4CF
78 | 0xAC00..=0xD7A3
79 | 0xF900..=0xFAFF
80 | 0xFE30..=0xFE4F
81 | 0xFF00..=0xFF60
82 | 0xFFE0..=0xFFE6
83 | 0x1F300..=0x1FAFF
84 | 0x20000..=0x3FFFD
85 );
86 if wide { 2 } else { 1 }
87}
88
89#[must_use]
91pub fn str_width(s: &str) -> usize {
92 s.chars().map(char_width).sum()
93}
94
95#[must_use]
97pub fn truncate_to_width(s: &str, max: usize) -> String {
98 if str_width(s) <= max {
99 return s.to_string();
100 }
101 let mut out = String::new();
102 let mut w = 0;
103 for c in s.chars() {
104 let cw = char_width(c);
105 if w + cw + 1 > max {
106 break;
107 }
108 out.push(c);
109 w += cw;
110 }
111 out.push('…');
112 out
113}
114
115#[derive(Clone, Debug)]
117struct Frag {
118 text: String,
119 style: Style,
120 focus: Option<usize>,
121}
122
123#[must_use]
128#[allow(clippy::too_many_lines)]
129pub fn render_document(
130 body: &str,
131 width: u16,
132 theme: &Theme,
133 focused: Option<usize>,
134) -> RenderedDoc {
135 let width = usize::from(width.max(10));
136 let mut doc = RenderedDoc::default();
137 let lines: Vec<&str> = body.lines().collect();
138 let mut i = 0;
139
140 while i < lines.len() {
141 let line = lines[i];
142 let trimmed = line.trim_start();
143
144 if let Some(marker) = fence_marker(trimmed) {
146 let lang_tag = trimmed[3..].trim().to_string();
147 let mut code: Vec<&str> = Vec::new();
148 let mut j = i + 1;
149 while j < lines.len() && fence_marker(lines[j].trim_start()) != Some(marker) {
150 code.push(lines[j]);
151 j += 1;
152 }
153 render_code_block(&mut doc, &code, &lang_tag, width, *theme);
154 i = if j < lines.len() { j + 1 } else { j };
155 continue;
156 }
157
158 if let Some((level, text)) = parse_heading_line(line) {
160 let style = theme.accent().add_modifier(Modifier::BOLD);
161 let indent = " ".repeat(level.saturating_sub(1));
162 doc.headings.push(HeadingPos {
163 line: doc.lines.len(),
164 level,
165 text: text.to_string(),
166 });
167 let frags = parse_inline(text, style, *theme, &mut doc, focused);
168 let prefix = Span::styled(indent, Style::default());
169 push_wrapped(
170 &mut doc,
171 &frags,
172 width,
173 std::slice::from_ref(&prefix),
174 std::slice::from_ref(&prefix),
175 );
176 if level == 1 {
177 doc.lines
178 .push(Line::from(Span::styled("─".repeat(width), theme.dim())));
179 }
180 i += 1;
181 continue;
182 }
183
184 if is_hr(trimmed) {
186 doc.lines
187 .push(Line::from(Span::styled("─".repeat(width), theme.dim())));
188 i += 1;
189 continue;
190 }
191
192 if trimmed.starts_with('|') {
194 let mut j = i;
195 while j < lines.len() && lines[j].trim_start().starts_with('|') {
196 j += 1;
197 }
198 render_table(&mut doc, &lines[i..j], width, *theme);
199 i = j;
200 continue;
201 }
202
203 if trimmed.is_empty() {
205 doc.lines.push(Line::default());
206 i += 1;
207 continue;
208 }
209
210 if let Some((label, rest)) = footnote_def(trimmed) {
212 doc.footnote_defs.insert(label.clone(), doc.lines.len());
213 let marker = Span::styled(format!("[^{label}] "), theme.accent());
214 let cont = Span::styled(
215 " ".repeat(str_width(&format!("[^{label}] "))),
216 Style::default(),
217 );
218 let frags = parse_inline(rest, theme.dim(), *theme, &mut doc, focused);
219 push_wrapped(
220 &mut doc,
221 &frags,
222 width,
223 std::slice::from_ref(&marker),
224 std::slice::from_ref(&cont),
225 );
226 i += 1;
227 continue;
228 }
229
230 if let Some(rest) = trimmed.strip_prefix('>') {
232 let gutter = Span::styled("│ ", theme.dim());
233 let frags = parse_inline(rest.trim_start(), theme.dim(), *theme, &mut doc, focused);
234 push_wrapped(
235 &mut doc,
236 &frags,
237 width,
238 std::slice::from_ref(&gutter),
239 std::slice::from_ref(&gutter),
240 );
241 i += 1;
242 continue;
243 }
244
245 if let Some((marker, rest)) = list_marker(line) {
247 let indent = line.len() - trimmed.len();
248 let pad = " ".repeat(indent);
249 let first = Span::styled(format!("{pad}{marker} "), theme.accent());
250 let cont = Span::raw(" ".repeat(indent + str_width(&marker) + 1));
251 let frags = parse_inline(rest, Style::default(), *theme, &mut doc, focused);
252 push_wrapped(
253 &mut doc,
254 &frags,
255 width,
256 std::slice::from_ref(&first),
257 std::slice::from_ref(&cont),
258 );
259 i += 1;
260 continue;
261 }
262
263 let frags = parse_inline(line, Style::default(), *theme, &mut doc, focused);
265 push_wrapped(&mut doc, &frags, width, &[], &[]);
266 i += 1;
267 }
268
269 doc
270}
271
272fn fence_marker(trimmed: &str) -> Option<char> {
273 if trimmed.starts_with("```") {
274 Some('`')
275 } else if trimmed.starts_with("~~~") {
276 Some('~')
277 } else {
278 None
279 }
280}
281
282fn is_hr(trimmed: &str) -> bool {
283 trimmed.len() >= 3
284 && (trimmed.chars().all(|c| c == '-')
285 || trimmed.chars().all(|c| c == '*')
286 || trimmed.chars().all(|c| c == '_'))
287}
288
289fn footnote_def(trimmed: &str) -> Option<(String, &str)> {
291 let inner = trimmed.strip_prefix("[^")?;
292 let close = inner.find("]:")?;
293 let label = inner[..close].trim();
294 if label.is_empty() {
295 return None;
296 }
297 Some((label.to_string(), inner[close + 2..].trim_start()))
298}
299
300fn list_marker(line: &str) -> Option<(String, &str)> {
303 let trimmed = line.trim_start();
304 for bullet in ["- [ ] ", "* [ ] "] {
305 if let Some(rest) = trimmed.strip_prefix(bullet) {
306 return Some(("☐".to_string(), rest));
307 }
308 }
309 for bullet in ["- [x] ", "* [x] ", "- [X] ", "* [X] "] {
310 if let Some(rest) = trimmed.strip_prefix(bullet) {
311 return Some(("☑".to_string(), rest));
312 }
313 }
314 for bullet in ["- ", "* ", "+ "] {
315 if let Some(rest) = trimmed.strip_prefix(bullet) {
316 return Some(("•".to_string(), rest));
317 }
318 }
319 let digits: String = trimmed.chars().take_while(char::is_ascii_digit).collect();
321 if !digits.is_empty() {
322 let rest = &trimmed[digits.len()..];
323 if let Some(text) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
324 return Some((format!("{digits}."), text));
325 }
326 }
327 None
328}
329
330fn render_code_block(
333 doc: &mut RenderedDoc,
334 code: &[&str],
335 lang_tag: &str,
336 width: usize,
337 theme: Theme,
338) {
339 let inner = width.saturating_sub(4).max(4);
340 let source = code.join("\n");
341 let verdict = if lang_tag.is_empty() || Language::from_tag(lang_tag) == Language::Unknown {
342 None
343 } else {
344 Some(check_syntax(lang_tag, &source))
345 };
346 let mut title = String::new();
347 if !lang_tag.is_empty() {
348 title.push_str(lang_tag);
349 }
350 let (badge, badge_style) = match &verdict {
351 Some(Ok(())) => (format!(" syntax {GLYPH_OK}"), theme.ok()),
352 Some(Err(e)) => (format!(" syntax {GLYPH_BROKEN} {e}"), theme.error()),
353 None => (String::new(), theme.dim()),
354 };
355 let head = format!("┌ {title}");
356 let head_width = str_width(&head) + str_width(&badge);
357 let fill = width.saturating_sub(head_width + 2);
358 doc.lines.push(Line::from(vec![
359 Span::styled(head, theme.dim()),
360 Span::styled(
361 truncate_to_width(&badge, width.saturating_sub(4)),
362 badge_style,
363 ),
364 Span::styled(format!(" {}", "─".repeat(fill)), theme.dim()),
365 ]));
366 for line in code {
367 let text = truncate_to_width(line, inner);
368 doc.lines.push(Line::from(vec![
369 Span::styled("│ ", theme.dim()),
370 Span::raw(text),
371 ]));
372 }
373 doc.lines.push(Line::from(Span::styled(
374 format!("└{}", "─".repeat(width.saturating_sub(1))),
375 theme.dim(),
376 )));
377}
378
379fn render_table(doc: &mut RenderedDoc, rows: &[&str], width: usize, theme: Theme) {
382 let parsed: Vec<Vec<String>> = rows
383 .iter()
384 .filter(|r| !is_table_separator(r))
385 .map(|r| {
386 r.trim()
387 .trim_matches('|')
388 .split('|')
389 .map(|c| c.trim().to_string())
390 .collect()
391 })
392 .collect();
393 if parsed.is_empty() {
394 return;
395 }
396 let cols = parsed.iter().map(Vec::len).max().unwrap_or(0);
397 let mut widths = vec![0usize; cols];
398 for row in &parsed {
399 for (c, cell) in row.iter().enumerate() {
400 widths[c] = widths[c].max(str_width(cell));
401 }
402 }
403 let total: usize = widths.iter().sum::<usize>() + cols * 3 + 1;
404 if total > width {
405 for row in rows {
407 doc.lines.push(Line::from(Span::raw((*row).to_string())));
408 }
409 return;
410 }
411 let rule = |l: &str, m: &str, r: &str| {
412 let mut s = String::from(l);
413 for (c, w) in widths.iter().enumerate() {
414 s.push_str(&"─".repeat(w + 2));
415 s.push_str(if c + 1 == cols { r } else { m });
416 }
417 Line::from(Span::styled(s, theme.dim()))
418 };
419 doc.lines.push(rule("┌", "┬", "┐"));
420 for (r, row) in parsed.iter().enumerate() {
421 let mut spans = vec![Span::styled("│", theme.dim())];
422 for (c, w) in widths.iter().enumerate() {
423 let cell = row.get(c).map_or("", String::as_str);
424 let pad = w.saturating_sub(str_width(cell));
425 let style = if r == 0 {
426 Style::default().add_modifier(Modifier::BOLD)
427 } else {
428 Style::default()
429 };
430 spans.push(Span::styled(format!(" {cell}{} ", " ".repeat(pad)), style));
431 spans.push(Span::styled("│", theme.dim()));
432 }
433 doc.lines.push(Line::from(spans));
434 if r == 0 && parsed.len() > 1 {
435 doc.lines.push(rule("├", "┼", "┤"));
436 }
437 }
438 doc.lines.push(rule("└", "┴", "┘"));
439}
440
441fn is_table_separator(row: &str) -> bool {
442 let t = row.trim().trim_matches('|');
443 !t.is_empty() && t.chars().all(|c| matches!(c, '-' | ':' | '|' | ' '))
444}
445
446#[allow(clippy::too_many_lines)]
449fn parse_inline(
450 text: &str,
451 base: Style,
452 theme: Theme,
453 doc: &mut RenderedDoc,
454 focused: Option<usize>,
455) -> Vec<Frag> {
456 let chars: Vec<char> = text.chars().collect();
457 let mut frags: Vec<Frag> = Vec::new();
458 let mut buf = String::new();
459 let mut i = 0;
460 let mut bold = false;
461 let mut italic = false;
462
463 let flush = |buf: &mut String, frags: &mut Vec<Frag>, bold: bool, italic: bool| {
464 if buf.is_empty() {
465 return;
466 }
467 let mut style = base;
468 if bold {
469 style = style.add_modifier(Modifier::BOLD);
470 }
471 if italic {
472 style = style.add_modifier(Modifier::ITALIC);
473 }
474 frags.push(Frag {
475 text: std::mem::take(buf),
476 style,
477 focus: None,
478 });
479 };
480
481 while i < chars.len() {
482 let c = chars[i];
483 if c == '`'
485 && let Some(close) = chars[i + 1..].iter().position(|&x| x == '`')
486 {
487 flush(&mut buf, &mut frags, bold, italic);
488 let code: String = chars[i + 1..i + 1 + close].iter().collect();
489 frags.push(Frag {
490 text: code,
491 style: theme.accent().add_modifier(Modifier::DIM),
492 focus: None,
493 });
494 i += close + 2;
495 continue;
496 }
497 if c == '*' {
499 if chars.get(i + 1) == Some(&'*') {
500 flush(&mut buf, &mut frags, bold, italic);
501 bold = !bold;
502 i += 2;
503 continue;
504 }
505 flush(&mut buf, &mut frags, bold, italic);
506 italic = !italic;
507 i += 1;
508 continue;
509 }
510 if c == '['
512 && chars.get(i + 1) == Some(&'^')
513 && let Some(close) = chars[i + 2..].iter().position(|&x| x == ']')
514 {
515 let label: String = chars[i + 2..i + 2 + close].iter().collect();
516 if !label.is_empty() && chars.get(i + 2 + close + 1) != Some(&':') {
517 flush(&mut buf, &mut frags, bold, italic);
518 let focus_id = doc.links.len();
519 doc.links.push(FocusTarget {
520 line: usize::MAX, kind: FocusKind::Footnote(label.clone()),
522 });
523 let mut style = theme.accent();
524 if focused == Some(focus_id) {
525 style = style.add_modifier(Modifier::REVERSED);
526 }
527 frags.push(Frag {
528 text: format!("[^{label}]"),
529 style,
530 focus: Some(focus_id),
531 });
532 i += 2 + close + 1;
533 continue;
534 }
535 }
536 if c == '['
538 && !okf_core::markdown::is_escaped(&chars, i)
539 && let Some((ltext, dest, next)) = okf_core::markdown::parse_inline_link(&chars, i)
540 {
541 flush(&mut buf, &mut frags, bold, italic);
542 let target = okf_core::markdown::clean_destination(&dest);
543 let kind = Link::classify(&target);
544 let focus_id = doc.links.len();
545 doc.links.push(FocusTarget {
546 line: usize::MAX,
547 kind: FocusKind::Link {
548 text: ltext.clone(),
549 target: target.clone(),
550 kind,
551 },
552 });
553 let (mut style, rendered) = if kind == LinkKind::External {
554 (
555 theme.accent().add_modifier(Modifier::UNDERLINED),
556 ltext.clone(),
557 )
558 } else {
559 (base.add_modifier(Modifier::UNDERLINED), format!("→{ltext}"))
560 };
561 if focused == Some(focus_id) {
562 style = style.add_modifier(Modifier::REVERSED);
563 }
564 frags.push(Frag {
565 text: rendered,
566 style,
567 focus: Some(focus_id),
568 });
569 i = next;
570 continue;
571 }
572 buf.push(c);
573 i += 1;
574 }
575 flush(&mut buf, &mut frags, bold, italic);
576 frags
577}
578
579fn push_wrapped(
583 doc: &mut RenderedDoc,
584 frags: &[Frag],
585 width: usize,
586 first_prefix: &[Span<'static>],
587 cont_prefix: &[Span<'static>],
588) {
589 struct Atom {
591 text: String,
592 style: Style,
593 focus: Option<usize>,
594 is_space: bool,
595 }
596 let mut atoms: Vec<Atom> = Vec::new();
597 for frag in frags {
598 let mut cur = String::new();
599 let mut cur_space = None;
600 for c in frag.text.chars() {
601 let space = c == ' ';
602 if cur_space != Some(space) && !cur.is_empty() {
603 atoms.push(Atom {
604 text: std::mem::take(&mut cur),
605 style: frag.style,
606 focus: frag.focus,
607 is_space: cur_space == Some(true),
608 });
609 }
610 cur_space = Some(space);
611 cur.push(c);
612 }
613 if !cur.is_empty() {
614 atoms.push(Atom {
615 text: cur,
616 style: frag.style,
617 focus: frag.focus,
618 is_space: cur_space == Some(true),
619 });
620 }
621 }
622
623 let prefix_width: usize = first_prefix.iter().map(|s| str_width(&s.content)).sum();
624 let avail = width.saturating_sub(prefix_width).max(8);
625 let mut lines: Vec<Vec<Span<'static>>> = vec![Vec::new()];
626 let mut cur_width = 0usize;
627 let mut focus_lines: Vec<(usize, usize)> = Vec::new();
628
629 for atom in atoms {
630 let w = str_width(&atom.text);
631 if cur_width + w > avail && cur_width > 0 && !atom.is_space {
632 lines.push(Vec::new());
633 cur_width = 0;
634 }
635 if atom.is_space && cur_width == 0 && lines.last().is_some_and(Vec::is_empty) {
636 continue; }
638 if let Some(f) = atom.focus {
639 focus_lines.push((f, lines.len() - 1));
640 }
641 lines
642 .last_mut()
643 .expect("lines is never empty")
644 .push(Span::styled(atom.text, atom.style));
645 cur_width += w;
646 }
647
648 let base = doc.lines.len();
649 for (idx, spans) in lines.into_iter().enumerate() {
650 let mut full = if idx == 0 {
651 first_prefix.to_vec()
652 } else {
653 cont_prefix.to_vec()
654 };
655 full.extend(spans);
656 doc.lines.push(Line::from(full));
657 }
658 for (f, rel) in focus_lines {
659 if let Some(target) = doc.links.get_mut(f) {
660 target.line = base + rel;
661 }
662 }
663}