1use std::path::Path;
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum PreviewKind {
13 Markdown,
14 Json,
15 Plain,
16 Image,
17 Csv,
18 Npy,
19 Audio,
20}
21
22impl PreviewKind {
23 pub fn from_ext(ext: Option<&str>) -> Self {
24 match ext.map(|e| e.to_lowercase()).as_deref() {
25 Some("md" | "mdx" | "markdown" | "mdown") => PreviewKind::Markdown,
26 Some("json" | "jsonc") => PreviewKind::Json,
27 Some(e) if crate::media::is_image_ext(e) => PreviewKind::Image,
28 Some(e) if crate::media::is_csv_ext(e) => PreviewKind::Csv,
29 Some(e) if crate::media::is_npy_ext(e) => PreviewKind::Npy,
30 Some(e) if crate::media::is_audio_ext(e) => PreviewKind::Audio,
31 _ => PreviewKind::Plain,
32 }
33 }
34
35 pub fn label(self) -> &'static str {
36 match self {
37 PreviewKind::Markdown => "Markdown",
38 PreviewKind::Json => "JSON",
39 PreviewKind::Plain => "Plain",
40 PreviewKind::Image => "Image",
41 PreviewKind::Csv => "CSV",
42 PreviewKind::Npy => "NumPy",
43 PreviewKind::Audio => "Audio",
44 }
45 }
46
47 pub fn is_media(self) -> bool {
48 matches!(
49 self,
50 PreviewKind::Image | PreviewKind::Csv | PreviewKind::Npy | PreviewKind::Audio
51 )
52 }
53}
54
55#[derive(Debug, Clone)]
56pub struct PreviewState {
57 pub open: bool,
58 pub scroll: usize,
59 pub kind: Option<PreviewKind>,
60 pub lines: Vec<PreviewLine>,
62 pub source_len: usize,
63 pub opened_at: Option<std::time::Instant>,
66 pub anim_pending: bool,
67 pub hscroll: usize,
69 pub base_dir: Option<std::path::PathBuf>,
71 pub cell_dims: (u32, u32),
73 pub anim_from: f32,
75 pub anim_to: f32,
76 pub closing: bool,
78 pub just_closed: bool,
80 pub media_path: Option<std::path::PathBuf>,
82}
83
84pub const PREVIEW_ANIM_MS: u64 = 420;
86
87#[derive(Debug, Clone)]
89pub struct ImageBlock {
90 pub path: String,
92 pub rows: u16,
94 pub w_cells: u16,
96}
97
98#[derive(Debug, Clone)]
99pub struct PreviewLine {
100 pub spans: Vec<(String, PreviewStyle)>,
101 pub image: Option<ImageBlock>,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum PreviewStyle {
107 Normal,
108 H1,
109 H2,
110 H3,
111 H4,
112 H5,
113 H6,
114 Bold,
115 Italic,
116 BoldItalic,
117 Strike,
118 Code,
119 CodeBlock,
120 CodeLang,
121 Link,
122 Image,
123 Quote,
124 AlertNote,
125 AlertTip,
126 AlertImportant,
127 AlertWarning,
128 AlertCaution,
129 ListBullet,
130 TaskDone,
131 TaskTodo,
132 Hr,
133 Dim,
134 Footnote,
135 Kbd,
136 Html,
137 JsonKey,
138 JsonString,
139 JsonNumber,
140 JsonLit,
141}
142
143impl Default for PreviewState {
144 fn default() -> Self {
145 Self {
146 open: false,
147 scroll: 0,
148 kind: None,
149 lines: Vec::new(),
150 source_len: 0,
151 opened_at: None,
152 anim_pending: false,
153 hscroll: 0,
154 base_dir: None,
155 cell_dims: (14, 28),
156 anim_from: 0.0,
157 anim_to: 1.0,
158 closing: false,
159 just_closed: false,
160 media_path: None,
161 }
162 }
163}
164
165impl PreviewState {
166 pub fn new() -> Self {
167 Self::default()
168 }
169
170 pub fn visible(&self) -> bool {
171 self.open
172 }
173
174 pub fn is_animating(&self) -> bool {
175 self.anim_pending
176 || self.closing
177 || self
178 .opened_at
179 .is_some_and(|t| t.elapsed().as_millis() < PREVIEW_ANIM_MS as u128)
180 }
181
182 pub fn close_immediate(&mut self) {
184 self.open = false;
185 self.closing = false;
186 self.just_closed = false;
187 self.scroll = 0;
188 self.hscroll = 0;
189 self.lines.clear();
190 self.kind = None;
191 self.source_len = 0;
192 self.opened_at = None;
193 self.anim_pending = false;
194 self.anim_from = 0.0;
195 self.anim_to = 0.0;
196 self.media_path = None;
197 }
198
199 pub fn close(&mut self) {
201 if !self.open || self.closing {
202 return;
203 }
204 let current = self.snapshot_openness();
205 self.closing = true;
206 self.anim_from = current;
207 self.anim_to = 0.0;
208 self.anim_pending = true;
209 self.opened_at = None;
210 }
211
212 pub fn open_for(&mut self, text: &str, ext: Option<&str>) {
213 self.open = true;
214 self.closing = false;
215 self.just_closed = false;
216 self.scroll = 0;
217 self.kind = Some(PreviewKind::from_ext(ext));
218 let from = if self.opened_at.is_some() || self.anim_pending {
219 self.snapshot_openness()
220 } else {
221 0.0
222 };
223 self.anim_from = from;
224 self.anim_to = 1.0;
225 self.anim_pending = true;
226 self.opened_at = None;
227 self.rebuild(text, ext);
228 }
229
230 pub fn anim_progress(&mut self) -> f32 {
232 let v = self.tick_openness();
233 if self.closing && v <= 0.001 {
234 self.finish_close();
235 }
236 v
237 }
238
239 fn snapshot_openness(&self) -> f32 {
240 if self.anim_pending {
241 return self.anim_from;
242 }
243 let Some(t0) = self.opened_at else {
244 return if self.open && !self.closing {
245 1.0
246 } else {
247 0.0
248 };
249 };
250 let u = (t0.elapsed().as_millis() as f32 / PREVIEW_ANIM_MS as f32).min(1.0);
251 self.anim_from + (self.anim_to - self.anim_from) * u
252 }
253
254 fn tick_openness(&mut self) -> f32 {
255 if self.anim_pending {
256 self.anim_pending = false;
257 self.opened_at = Some(std::time::Instant::now());
258 return self.anim_from;
259 }
260 let Some(t0) = self.opened_at else {
261 return if self.open && !self.closing {
262 1.0
263 } else {
264 0.0
265 };
266 };
267 let u = (t0.elapsed().as_millis() as f32 / PREVIEW_ANIM_MS as f32).min(1.0);
268 self.anim_from + (self.anim_to - self.anim_from) * u
269 }
270
271 fn finish_close(&mut self) {
272 self.open = false;
273 self.closing = false;
274 self.just_closed = true;
275 self.scroll = 0;
276 self.lines.clear();
277 self.kind = None;
278 self.source_len = 0;
279 self.opened_at = None;
280 self.anim_pending = false;
281 self.anim_from = 0.0;
282 self.anim_to = 0.0;
283 self.media_path = None;
284 }
285
286 pub fn open_path(&mut self, path: &std::path::Path) -> Result<(), String> {
288 let ext = path
289 .extension()
290 .and_then(|e| e.to_str())
291 .map(|e| e.to_ascii_lowercase());
292 let kind = PreviewKind::from_ext(ext.as_deref());
293 self.open = true;
294 self.closing = false;
295 self.just_closed = false;
296 self.scroll = 0;
297 self.kind = Some(kind);
298 self.media_path = Some(path.to_path_buf());
299 self.anim_from = 0.0;
300 self.anim_to = 1.0;
301 self.anim_pending = true;
302 self.opened_at = None;
303
304 match kind {
305 PreviewKind::Image => {
306 let name = path.display().to_string();
307 self.lines = vec![
308 PreviewLine {
309 spans: vec![(" Image preview".into(), PreviewStyle::H2)],
310 image: None,
311 },
312 PreviewLine {
313 spans: vec![(format!(" {name}"), PreviewStyle::Dim)],
314 image: None,
315 },
316 PreviewLine {
317 spans: vec![("".into(), PreviewStyle::Normal)],
318 image: None,
319 },
320 PreviewLine {
321 spans: vec![(
322 " ←/→ or h/l resize · Esc close".into(),
323 PreviewStyle::Dim,
324 )],
325 image: None,
326 },
327 PreviewLine {
328 spans: vec![(
329 " (Kitty/Ghostty + gpu_acc for full-res image)".into(),
330 PreviewStyle::Dim,
331 )],
332 image: None,
333 },
334 ];
335 self.source_len = 0;
336 Ok(())
337 }
338 PreviewKind::Csv => {
339 let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
340 let tsv = ext.as_deref() == Some("tsv");
341 self.lines = crate::media::render_csv(&text, tsv);
342 self.source_len = text.len();
343 Ok(())
344 }
345 PreviewKind::Npy => {
346 self.lines = crate::media::render_npy(path)?;
347 self.source_len = 0;
348 Ok(())
349 }
350 PreviewKind::Audio => {
351 self.lines = crate::media::audio_info_lines(path, false);
352 self.source_len = 0;
353 Ok(())
354 }
355 PreviewKind::Markdown | PreviewKind::Json | PreviewKind::Plain => {
356 let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
357 self.media_path = Some(path.to_path_buf());
358 self.rebuild(&text, ext.as_deref());
359 Ok(())
360 }
361 }
362 }
363
364 pub fn take_just_closed(&mut self) -> bool {
365 if self.just_closed {
366 self.just_closed = false;
367 true
368 } else {
369 false
370 }
371 }
372
373 pub fn rebuild(&mut self, text: &str, ext: Option<&str>) {
374 let kind = PreviewKind::from_ext(ext);
375 self.kind = Some(kind);
376 self.source_len = text.len();
377 self.lines = match kind {
378 PreviewKind::Markdown => {
379 render_markdown(text, self.base_dir.as_deref(), self.cell_dims)
380 }
381 PreviewKind::Json => render_json(text),
382 PreviewKind::Plain => render_plain(text),
383 PreviewKind::Csv => {
384 let tsv = ext.map(|e| e.eq_ignore_ascii_case("tsv")).unwrap_or(false);
385 crate::media::render_csv(text, tsv)
386 }
387 PreviewKind::Image | PreviewKind::Npy | PreviewKind::Audio => {
389 if self.lines.is_empty() {
390 render_plain(text)
391 } else {
392 self.lines.clone()
393 }
394 }
395 };
396 let max_scroll = self.lines.len().saturating_sub(1);
397 if self.scroll > max_scroll {
398 self.scroll = max_scroll;
399 }
400 }
401
402 pub fn scroll_by(&mut self, delta: isize, page: usize) {
403 let max = self.lines.len().saturating_sub(page.max(1));
404 let cur = self.scroll as isize + delta;
405 self.scroll = cur.clamp(0, max as isize) as usize;
406 }
407}
408
409fn image_block_for(url: &str, base: Option<&Path>, cell: (u32, u32)) -> Option<ImageBlock> {
414 if url.starts_with("http://") || url.starts_with("https://") {
415 return None; }
417 let p = Path::new(url);
418 let path = if p.is_absolute() {
419 p.to_path_buf()
420 } else {
421 base?.join(p)
422 };
423 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
424 if !crate::media::is_image_ext(&ext) {
425 return None;
426 }
427 let (iw, ih) = image::image_dimensions(&path).ok()?;
428 if iw == 0 || ih == 0 {
429 return None;
430 }
431 let (cw, ch) = (cell.0.max(4), cell.1.max(6));
432 let w_cells: u16 = 56;
433 let disp_w_px = w_cells as u32 * cw;
434 let disp_h_px = (disp_w_px as u64 * ih as u64 / iw as u64) as u32;
435 let rows = (disp_h_px.div_ceil(ch)).clamp(3, 18) as u16;
436 Some(ImageBlock {
437 path: path.display().to_string(),
438 rows,
439 w_cells,
440 })
441}
442
443fn render_markdown(
444 text: &str,
445 base: Option<&Path>,
446 cell: (u32, u32),
447) -> Vec<PreviewLine> {
448 let mut out = Vec::new();
449 let lines: Vec<&str> = text.lines().collect();
450 let mut li = 0;
451 let mut in_code = false;
452 let mut code_fence = String::new(); let mut code_lang = String::new();
454 let mut footnotes: Vec<(String, String)> = Vec::new();
455
456 if lines.first().map(|l| l.trim() == "---").unwrap_or(false) {
458 li = 1;
459 while li < lines.len() {
460 if lines[li].trim() == "---" || lines[li].trim() == "..." {
461 li += 1;
462 break;
463 }
464 li += 1;
465 }
466 out.push(pl(vec![(" · front matter ·".into(), PreviewStyle::Dim)]));
468 out.push(pl(vec![("".into(), PreviewStyle::Normal)]));
469 }
470
471 while li < lines.len() {
472 let line = lines[li];
473
474 if !in_code && line.trim_start().starts_with("<!--") {
476 if line.contains("-->") {
477 li += 1;
478 continue;
479 }
480 li += 1;
481 while li < lines.len() && !lines[li].contains("-->") {
482 li += 1;
483 }
484 if li < lines.len() {
485 li += 1;
486 }
487 continue;
488 }
489
490 if let Some((fence, rest)) = detect_fence(line) {
492 if in_code {
493 if fence.starts_with(&code_fence) || code_fence.starts_with(&fence[..fence.len().min(3)]) {
494 if fence.chars().next() == code_fence.chars().next()
496 && fence.len() >= code_fence.len()
497 && rest.trim().is_empty()
498 {
499 in_code = false;
500 code_fence.clear();
501 code_lang.clear();
502 out.push(pl(vec![("└────────────────────────".into(), PreviewStyle::Dim)]));
503 li += 1;
504 continue;
505 }
506 }
507 out.push(code_body_line(line));
509 li += 1;
510 continue;
511 } else {
512 in_code = true;
513 code_fence = fence;
514 code_lang = rest.trim().to_string();
515 let lang = code_lang.split_whitespace().next().unwrap_or("");
517 let header = if lang.is_empty() {
518 "┌ code".to_string()
519 } else {
520 format!("┌ {lang}")
521 };
522 out.push(pl(vec![
523 (header, PreviewStyle::CodeLang),
524 (" ────────────────".into(), PreviewStyle::Dim),
525 ]));
526 li += 1;
527 continue;
528 }
529 }
530 if in_code {
531 out.push(code_body_line(line));
532 li += 1;
533 continue;
534 }
535
536 if let Some((id, body)) = parse_footnote_def(line) {
538 footnotes.push((id, body));
539 li += 1;
540 continue;
541 }
542
543 if line.contains('|') && li + 1 < lines.len() && is_table_separator(lines[li + 1]) {
545 let table_rows: Vec<&str> = lines[li..]
546 .iter()
547 .take_while(|l| l.contains('|') && !l.trim().is_empty())
548 .copied()
549 .collect();
550 let aligns = parse_table_aligns(lines[li + 1]);
551 out.extend(render_table(&table_rows, &aligns));
552 li += table_rows.len();
553 continue;
554 }
555
556 if li + 1 < lines.len() && !line.trim().is_empty() {
558 let under = lines[li + 1].trim();
559 if is_setext_underline(under, '=') {
560 out.extend(heading_lines(1, line.trim()));
561 li += 2;
562 continue;
563 }
564 if is_setext_underline(under, '-') {
565 out.extend(heading_lines(2, line.trim()));
566 li += 2;
567 continue;
568 }
569 }
570
571 if is_hr(line) {
573 out.push(pl(vec![("─".repeat(56), PreviewStyle::Hr)]));
574 li += 1;
575 continue;
576 }
577
578 if let Some((level, title)) = parse_atx_heading(line) {
580 out.extend(heading_lines(level, &title));
581 li += 1;
582 continue;
583 }
584
585 if is_indented_code_start(line) && !looks_like_list(line) {
587 let mut block = Vec::new();
588 while li < lines.len()
589 && (is_indented_code_start(lines[li])
590 || (lines[li].trim().is_empty()
591 && li + 1 < lines.len()
592 && is_indented_code_start(lines[li + 1])))
593 {
594 if lines[li].trim().is_empty() {
595 block.push(String::new());
596 } else {
597 block.push(strip_indent_code(lines[li]));
598 }
599 li += 1;
600 }
601 out.push(pl(vec![("┌ code".into(), PreviewStyle::CodeLang)]));
602 for b in block {
603 out.push(code_body_line(&b));
604 }
605 out.push(pl(vec![("└────────────────────────".into(), PreviewStyle::Dim)]));
606 continue;
607 }
608
609 if line.trim_start().starts_with('>') {
611 let (quote_lines, consumed) = collect_blockquote(&lines[li..]);
612 out.extend(render_blockquote("e_lines));
613 li += consumed;
614 continue;
615 }
616
617 if let Some(item) = parse_list_item(line) {
619 let (items, consumed) = collect_list(&lines[li..], item.indent);
620 out.extend(render_list(&items));
621 li += consumed;
622 continue;
623 }
624
625 if line.trim().is_empty() {
627 out.push(pl(vec![("".into(), PreviewStyle::Normal)]));
628 li += 1;
629 continue;
630 }
631
632 {
634 let t = line.trim();
635 let chars: Vec<char> = t.chars().collect();
636 if chars.first() == Some(&'!') && chars.get(1) == Some(&'[') {
637 if let Some((alt, url, next)) = parse_md_image(&chars, 0) {
638 if next >= chars.len() {
639 if let Some(block) = image_block_for(&url, base, cell) {
640 let cap = if alt.is_empty() {
641 format!("🖼 {url}")
642 } else {
643 format!("🖼 {alt}")
644 };
645 let mut anchor = pl(vec![(format!(" {cap}"), PreviewStyle::Image)]);
646 let rows = block.rows;
647 anchor.image = Some(block);
648 out.push(anchor);
649 for _ in 0..rows {
650 out.push(pl(vec![("".into(), PreviewStyle::Normal)]));
651 }
652 li += 1;
653 continue;
654 }
655 }
656 }
657 }
658 }
659
660 let (para, consumed) = collect_paragraph(&lines[li..]);
662 let joined = para.join(" ");
663 out.push(pl(inline_md(&joined)));
664 li += consumed;
665 }
666
667 if !footnotes.is_empty() {
669 out.push(pl(vec![("".into(), PreviewStyle::Normal)]));
670 out.push(pl(vec![("─".repeat(40), PreviewStyle::Hr)]));
671 out.push(pl(vec![("Footnotes".into(), PreviewStyle::H4)]));
672 for (id, body) in footnotes {
673 let mut spans = vec![(format!("[^{id}] "), PreviewStyle::Footnote)];
674 spans.extend(inline_md(&body));
675 out.push(pl(spans));
676 }
677 }
678
679 if out.is_empty() {
680 out.push(pl(vec![("(empty document)".into(), PreviewStyle::Dim)]));
681 }
682 out
683}
684
685fn detect_fence(line: &str) -> Option<(String, String)> {
686 let t = line.trim_start();
687 let indent = line.len() - t.len();
688 if indent > 3 {
689 return None; }
691 let c = t.chars().next()?;
692 if c != '`' && c != '~' {
693 return None;
694 }
695 let n = t.chars().take_while(|&ch| ch == c).count();
696 if n < 3 {
697 return None;
698 }
699 let rest = &t[n..];
701 if c == '`' && rest.contains('`') {
702 return None;
703 }
704 Some((t[..n].to_string(), rest.to_string()))
705}
706
707fn code_body_line(line: &str) -> PreviewLine {
708 pl(vec![
709 ("│ ".into(), PreviewStyle::Dim),
710 (line.to_string(), PreviewStyle::CodeBlock),
711 ])
712}
713
714fn parse_footnote_def(line: &str) -> Option<(String, String)> {
715 let t = line.trim_start();
716 if !t.starts_with("[^") {
717 return None;
718 }
719 let end = t.find("]:")?;
720 let id = t[2..end].to_string();
721 if id.is_empty() {
722 return None;
723 }
724 let body = t[end + 2..].trim().to_string();
725 Some((id, body))
726}
727
728fn is_setext_underline(s: &str, ch: char) -> bool {
729 let t = s.trim();
730 !t.is_empty() && t.chars().all(|c| c == ch) && t.len() >= 1
731}
732
733fn is_hr(line: &str) -> bool {
734 let t: String = line
735 .trim()
736 .chars()
737 .filter(|c| !c.is_whitespace())
738 .collect();
739 if t.len() < 3 {
740 return false;
741 }
742 let first = t.chars().next().unwrap();
743 if first != '-' && first != '*' && first != '_' {
744 return false;
745 }
746 t.chars().all(|c| c == first)
747}
748
749fn parse_atx_heading(line: &str) -> Option<(usize, String)> {
750 let t = line.trim_start();
751 let hashes = t.chars().take_while(|c| *c == '#').count();
752 if hashes == 0 || hashes > 6 {
753 return None;
754 }
755 let rest = &t[hashes..];
756 if !rest.is_empty() && !rest.starts_with(|c: char| c.is_whitespace()) {
758 return None;
759 }
760 let mut title = rest.trim().to_string();
761 while title.ends_with('#') {
763 let trimmed = title.trim_end_matches('#').trim_end();
764 if trimmed.len() == title.len() {
765 break;
766 }
767 if title
769 .trim_end_matches('#')
770 .chars()
771 .last()
772 .is_none_or(|c| c.is_whitespace())
773 || trimmed.is_empty()
774 {
775 title = trimmed.to_string();
776 } else {
777 break;
778 }
779 }
780 Some((hashes, title))
781}
782
783fn heading_lines(level: usize, title: &str) -> Vec<PreviewLine> {
784 let style = match level {
785 1 => PreviewStyle::H1,
786 2 => PreviewStyle::H2,
787 3 => PreviewStyle::H3,
788 4 => PreviewStyle::H4,
789 5 => PreviewStyle::H5,
790 _ => PreviewStyle::H6,
791 };
792 let mut spans = inline_md(title);
793 for s in &mut spans {
794 if s.1 == PreviewStyle::Normal {
795 s.1 = style;
796 }
797 }
798 let marker = match level {
800 1 => "",
801 2 => "",
802 _ => "",
803 };
804 let mut out = Vec::new();
805 if level == 1 {
806 out.push(pl(spans));
807 out.push(pl(vec![("═".repeat(title_display_width(title).max(8).min(56)), PreviewStyle::Hr)]));
808 } else if level == 2 {
809 out.push(pl(spans));
810 out.push(pl(vec![("─".repeat(title_display_width(title).max(6).min(48)), PreviewStyle::Hr)]));
811 } else {
812 let _ = marker;
814 let indent = " ".repeat(level.saturating_sub(3));
815 let mut with_indent = vec![(indent, PreviewStyle::Dim)];
816 with_indent.extend(spans);
817 out.push(pl(with_indent));
818 }
819 out
820}
821
822fn title_display_width(s: &str) -> usize {
823 use unicode_width::UnicodeWidthStr;
824 UnicodeWidthStr::width(s)
825}
826
827fn is_indented_code_start(line: &str) -> bool {
828 line.starts_with(" ") || line.starts_with('\t')
829}
830
831fn strip_indent_code(line: &str) -> String {
832 if let Some(r) = line.strip_prefix(" ") {
833 r.to_string()
834 } else if let Some(r) = line.strip_prefix('\t') {
835 r.to_string()
836 } else {
837 line.to_string()
838 }
839}
840
841fn looks_like_list(line: &str) -> bool {
842 parse_list_item(line).is_some()
843}
844
845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
848enum AlertKind {
849 Note,
850 Tip,
851 Important,
852 Warning,
853 Caution,
854}
855
856fn collect_blockquote<'a>(lines: &[&'a str]) -> (Vec<&'a str>, usize) {
857 let mut out = Vec::new();
858 let mut i = 0;
859 while i < lines.len() {
860 let t = lines[i].trim_start();
861 if !t.starts_with('>') {
862 break;
863 }
864 out.push(lines[i]);
865 i += 1;
866 }
867 (out, i.max(1))
868}
869
870fn strip_quote_prefix(line: &str) -> String {
871 let t = line.trim_start();
872 if let Some(rest) = t.strip_prefix('>') {
873 if rest.starts_with(' ') {
874 rest[1..].to_string()
875 } else {
876 rest.to_string()
877 }
878 } else {
879 line.to_string()
880 }
881}
882
883fn parse_alert_marker(s: &str) -> Option<AlertKind> {
884 let t = s.trim();
885 let upper = t.to_ascii_uppercase();
886 let start = upper.strip_prefix("[!")?;
888 let end = start.find(']')?;
889 let kind = &start[..end];
890 match kind {
891 "NOTE" => Some(AlertKind::Note),
892 "TIP" => Some(AlertKind::Tip),
893 "IMPORTANT" => Some(AlertKind::Important),
894 "WARNING" => Some(AlertKind::Warning),
895 "CAUTION" => Some(AlertKind::Caution),
896 _ => None,
897 }
898}
899
900fn alert_style(k: AlertKind) -> (PreviewStyle, &'static str) {
901 match k {
902 AlertKind::Note => (PreviewStyle::AlertNote, "NOTE"),
903 AlertKind::Tip => (PreviewStyle::AlertTip, "TIP"),
904 AlertKind::Important => (PreviewStyle::AlertImportant, "IMPORTANT"),
905 AlertKind::Warning => (PreviewStyle::AlertWarning, "WARNING"),
906 AlertKind::Caution => (PreviewStyle::AlertCaution, "CAUTION"),
907 }
908}
909
910fn render_blockquote(raw_lines: &[&str]) -> Vec<PreviewLine> {
911 let mut out = Vec::new();
912 if raw_lines.is_empty() {
913 return out;
914 }
915 let bodies: Vec<String> = raw_lines.iter().map(|l| strip_quote_prefix(l)).collect();
916
917 let mut start = 0;
919 let mut alert: Option<AlertKind> = None;
920 if let Some(k) = parse_alert_marker(&bodies[0]) {
921 alert = Some(k);
922 start = 1;
923 }
924
925 let depth = quote_depth(raw_lines[0]);
926
927 if let Some(k) = alert {
928 let (st, label) = alert_style(k);
929 let bar = "┃ ".repeat(depth.max(1));
930 out.push(pl(vec![
931 (bar.clone(), st),
932 (format!("⚠ {label}"), st),
933 ]));
934 for b in bodies.iter().skip(start) {
935 if b.trim().is_empty() {
936 out.push(pl(vec![(bar.clone(), st)]));
937 } else {
938 let mut spans = vec![(bar.clone(), st)];
939 let mut body = inline_md(b);
940 for s in &mut body {
941 if s.1 == PreviewStyle::Normal {
942 s.1 = st;
943 }
944 }
945 spans.extend(body);
946 out.push(pl(spans));
947 }
948 }
949 } else {
950 for b in &bodies {
951 let bar = "│ ".repeat(depth.max(1));
952 if b.trim().is_empty() {
953 out.push(pl(vec![(bar, PreviewStyle::Quote)]));
954 } else {
955 let mut spans = vec![(bar, PreviewStyle::Quote)];
956 let mut body = inline_md(b);
957 for s in &mut body {
958 if s.1 == PreviewStyle::Normal {
959 s.1 = PreviewStyle::Quote;
960 }
961 }
962 spans.extend(body);
963 out.push(pl(spans));
964 }
965 }
966 }
967 out
968}
969
970fn quote_depth(line: &str) -> usize {
971 let mut d = 0;
972 let mut chars = line.trim_start().chars().peekable();
973 while chars.peek() == Some(&'>') {
974 d += 1;
975 chars.next();
976 if chars.peek() == Some(&' ') {
977 chars.next();
978 }
979 }
980 d.max(1)
981}
982
983#[derive(Debug, Clone)]
986struct ListItem {
987 indent: usize,
988 ordered: bool,
989 number: u32,
990 task: Option<bool>, text: String,
992}
993
994fn indent_width(line: &str) -> usize {
995 let mut w = 0;
996 for c in line.chars() {
997 match c {
998 ' ' => w += 1,
999 '\t' => w += 4,
1000 _ => break,
1001 }
1002 }
1003 w
1004}
1005
1006fn parse_list_item(line: &str) -> Option<ListItem> {
1007 let indent = indent_width(line);
1008 if indent > 40 {
1010 return None;
1011 }
1012 let byte_indent = {
1013 let mut b = 0;
1014 for c in line.chars() {
1015 if c == ' ' || c == '\t' {
1016 b += c.len_utf8();
1017 } else {
1018 break;
1019 }
1020 }
1021 b
1022 };
1023 let rest = &line[byte_indent..];
1024
1025 if let Some(r) = rest
1027 .strip_prefix("- ")
1028 .or_else(|| rest.strip_prefix("* "))
1029 .or_else(|| rest.strip_prefix("+ "))
1030 {
1031 let (task, text) = parse_task_prefix(r);
1032 return Some(ListItem {
1033 indent,
1034 ordered: false,
1035 number: 0,
1036 task,
1037 text,
1038 });
1039 }
1040 if let Some(pos) = rest.find(". ").or_else(|| rest.find(") ")) {
1042 let num = &rest[..pos];
1043 if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) {
1044 let sep_len = 2;
1045 let r = &rest[pos + sep_len..];
1046 let (task, text) = parse_task_prefix(r);
1047 let number = num.parse().unwrap_or(1);
1048 return Some(ListItem {
1049 indent,
1050 ordered: true,
1051 number,
1052 task,
1053 text,
1054 });
1055 }
1056 }
1057 None
1058}
1059
1060fn parse_task_prefix(s: &str) -> (Option<bool>, String) {
1061 let t = s.trim_start();
1062 if let Some(r) = t.strip_prefix("[x] ").or_else(|| t.strip_prefix("[X] ")) {
1063 return (Some(true), r.to_string());
1064 }
1065 if let Some(r) = t.strip_prefix("[ ] ") {
1066 return (Some(false), r.to_string());
1067 }
1068 (None, s.to_string())
1069}
1070
1071fn collect_list(lines: &[&str], base_indent: usize) -> (Vec<ListItem>, usize) {
1072 let mut items = Vec::new();
1073 let mut i = 0;
1074 while i < lines.len() {
1075 let line = lines[i];
1076 if line.trim().is_empty() {
1077 if i + 1 < lines.len() {
1079 if let Some(n) = parse_list_item(lines[i + 1]) {
1080 if n.indent >= base_indent {
1081 i += 1;
1082 continue;
1083 }
1084 }
1085 }
1086 break;
1087 }
1088 if let Some(item) = parse_list_item(line) {
1089 if item.indent < base_indent {
1090 break;
1091 }
1092 items.push(item);
1093 i += 1;
1094 while i < lines.len() {
1096 let cont = lines[i];
1097 if cont.trim().is_empty() {
1098 break;
1099 }
1100 if parse_list_item(cont).is_some() {
1101 break;
1102 }
1103 let iw = indent_width(cont);
1104 if iw > items.last().map(|x| x.indent).unwrap_or(0) {
1105 if let Some(last) = items.last_mut() {
1106 last.text.push(' ');
1107 last.text.push_str(cont.trim());
1108 }
1109 i += 1;
1110 } else {
1111 break;
1112 }
1113 }
1114 continue;
1115 }
1116 break;
1117 }
1118 (items, i.max(1))
1119}
1120
1121fn render_list(items: &[ListItem]) -> Vec<PreviewLine> {
1122 let mut out = Vec::new();
1123 let min_indent = items.iter().map(|i| i.indent).min().unwrap_or(0);
1124 for it in items {
1125 let level = (it.indent.saturating_sub(min_indent) / 2).min(6);
1126 let pad = " ".repeat(level);
1127 let mut spans = vec![(pad, PreviewStyle::Dim)];
1128 match it.task {
1129 Some(true) => {
1130 spans.push(("☑ ".into(), PreviewStyle::TaskDone));
1131 }
1132 Some(false) => {
1133 spans.push(("☐ ".into(), PreviewStyle::TaskTodo));
1134 }
1135 None if it.ordered => {
1136 spans.push((format!("{}. ", it.number), PreviewStyle::ListBullet));
1137 }
1138 None => {
1139 let bullet = match level % 3 {
1140 0 => "• ",
1141 1 => "◦ ",
1142 _ => "▪ ",
1143 };
1144 spans.push((bullet.into(), PreviewStyle::ListBullet));
1145 }
1146 }
1147 let mut body = inline_md(&it.text);
1148 if it.task == Some(true) {
1149 for s in &mut body {
1150 if s.1 == PreviewStyle::Normal {
1151 s.1 = PreviewStyle::Strike;
1152 }
1153 }
1154 }
1155 spans.extend(body);
1156 out.push(pl(spans));
1157 }
1158 out
1159}
1160
1161fn is_block_start(line: &str) -> bool {
1164 if line.trim().is_empty() {
1165 return true;
1166 }
1167 if detect_fence(line).is_some() {
1168 return true;
1169 }
1170 if is_hr(line) {
1171 return true;
1172 }
1173 if parse_atx_heading(line).is_some() {
1174 return true;
1175 }
1176 if line.trim_start().starts_with('>') {
1177 return true;
1178 }
1179 if parse_list_item(line).is_some() {
1180 return true;
1181 }
1182 if line.contains('|') {
1183 return true;
1184 }
1185 if is_indented_code_start(line) {
1186 return true;
1187 }
1188 if parse_footnote_def(line).is_some() {
1189 return true;
1190 }
1191 false
1192}
1193
1194fn collect_paragraph<'a>(lines: &[&'a str]) -> (Vec<&'a str>, usize) {
1195 let mut out = Vec::new();
1196 let mut i = 0;
1197 while i < lines.len() {
1198 let line = lines[i];
1199 if i > 0 && is_block_start(line) {
1200 break;
1201 }
1202 if line.trim().is_empty() {
1203 break;
1204 }
1205 out.push(line.trim_end());
1207 i += 1;
1208 if i < lines.len() {
1210 let u = lines[i].trim();
1211 if is_setext_underline(u, '=') || is_setext_underline(u, '-') {
1212 break;
1213 }
1214 }
1215 }
1216 (out, i.max(1))
1217}
1218
1219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1222enum ColAlign {
1223 Left,
1224 Center,
1225 Right,
1226}
1227
1228fn is_table_separator(line: &str) -> bool {
1229 let t = line.trim();
1230 if t.is_empty() || !t.contains('|') || !t.contains('-') {
1231 return false;
1232 }
1233 t.split('|')
1234 .filter(|s| !s.trim().is_empty())
1235 .all(|seg| {
1236 let s = seg.trim();
1237 !s.is_empty()
1238 && s.chars().all(|c| c == '-' || c == ':' || c == ' ')
1239 && s.contains('-')
1240 })
1241}
1242
1243fn parse_table_aligns(sep: &str) -> Vec<ColAlign> {
1244 split_table_row(sep)
1245 .into_iter()
1246 .map(|cell| {
1247 let s = cell.trim();
1248 let left = s.starts_with(':');
1249 let right = s.ends_with(':');
1250 match (left, right) {
1251 (true, true) => ColAlign::Center,
1252 (false, true) => ColAlign::Right,
1253 _ => ColAlign::Left,
1254 }
1255 })
1256 .collect()
1257}
1258
1259fn render_table(rows: &[&str], aligns: &[ColAlign]) -> Vec<PreviewLine> {
1260 use unicode_width::UnicodeWidthStr;
1261 let mut out = Vec::new();
1262 if rows.is_empty() {
1263 return out;
1264 }
1265
1266 let cells_all: Vec<Vec<String>> = rows
1267 .iter()
1268 .filter(|r| !is_table_separator(r))
1269 .map(|r| split_table_row(r))
1270 .collect();
1271 if cells_all.is_empty() {
1272 return out;
1273 }
1274
1275 let n_cols = cells_all.iter().map(|r| r.len()).max().unwrap_or(1);
1276 let mut col_widths = vec![3usize; n_cols];
1278 for row in &cells_all {
1279 for (i, cell) in row.iter().enumerate() {
1280 let plain = strip_md_for_width(cell.trim());
1281 let w = UnicodeWidthStr::width(plain.as_str());
1282 if i < col_widths.len() && w > col_widths[i] {
1283 col_widths[i] = w;
1284 }
1285 }
1286 }
1287
1288 let is_header = rows.len() >= 2;
1289 out.push(pl(vec![(make_table_border(&col_widths, '┌', '┬', '┐'), PreviewStyle::Hr)]));
1290
1291 for (row_i, row) in cells_all.iter().enumerate() {
1292 let mut spans = vec![("│".into(), PreviewStyle::Hr)];
1293 for i in 0..n_cols {
1294 let w = col_widths[i];
1295 let raw = row.get(i).map(|s| s.trim()).unwrap_or("");
1296 let align = aligns.get(i).copied().unwrap_or(ColAlign::Left);
1297 let cell_spans = if row_i == 0 && is_header {
1298 let mut s = inline_md(raw);
1299 for p in &mut s {
1300 if p.1 == PreviewStyle::Normal {
1301 p.1 = PreviewStyle::Bold;
1302 }
1303 }
1304 s
1305 } else {
1306 inline_md(raw)
1307 };
1308 let content_w: usize = cell_spans
1309 .iter()
1310 .map(|(t, _)| UnicodeWidthStr::width(t.as_str()))
1311 .sum();
1312 let pad = w.saturating_sub(content_w);
1313 let (left_pad, right_pad) = match align {
1314 ColAlign::Left => (0, pad),
1315 ColAlign::Right => (pad, 0),
1316 ColAlign::Center => (pad / 2, pad - pad / 2),
1317 };
1318 spans.push((" ".into(), PreviewStyle::Normal));
1319 if left_pad > 0 {
1320 spans.push((" ".repeat(left_pad), PreviewStyle::Normal));
1321 }
1322 spans.extend(cell_spans);
1323 if right_pad > 0 {
1324 spans.push((" ".repeat(right_pad), PreviewStyle::Normal));
1325 }
1326 spans.push((" │".into(), PreviewStyle::Hr));
1327 }
1328 out.push(pl(spans));
1329 if row_i == 0 && is_header {
1330 out.push(pl(vec![(
1331 make_table_border(&col_widths, '├', '┼', '┤'),
1332 PreviewStyle::Hr,
1333 )]));
1334 }
1335 }
1336 out.push(pl(vec![(make_table_border(&col_widths, '└', '┴', '┘'), PreviewStyle::Hr)]));
1337 out
1338}
1339
1340fn strip_md_for_width(s: &str) -> String {
1341 let mut out = s.to_string();
1343 for m in ["**", "__", "~~", "*", "_", "`"] {
1344 out = out.replace(m, "");
1345 }
1346 out
1347}
1348
1349fn make_table_border(col_widths: &[usize], left: char, mid: char, right: char) -> String {
1350 let mut s = String::from(left);
1351 for (i, w) in col_widths.iter().enumerate() {
1352 if i > 0 {
1353 s.push(mid);
1354 }
1355 s.push_str(&"─".repeat(w + 2));
1356 }
1357 s.push(right);
1358 s
1359}
1360
1361fn split_table_row(line: &str) -> Vec<String> {
1362 let t = line.trim();
1363 let inner = t.trim_start_matches('|').trim_end_matches('|');
1364 let mut cells = Vec::new();
1366 let mut cur = String::new();
1367 let chars: Vec<char> = inner.chars().collect();
1368 let mut i = 0;
1369 while i < chars.len() {
1370 if chars[i] == '\\' && i + 1 < chars.len() && chars[i + 1] == '|' {
1371 cur.push('|');
1372 i += 2;
1373 continue;
1374 }
1375 if chars[i] == '|' {
1376 cells.push(cur.trim().to_string());
1377 cur.clear();
1378 i += 1;
1379 continue;
1380 }
1381 cur.push(chars[i]);
1382 i += 1;
1383 }
1384 cells.push(cur.trim().to_string());
1385 cells
1386}
1387
1388fn inline_md(s: &str) -> Vec<(String, PreviewStyle)> {
1392 let chars: Vec<char> = s.chars().collect();
1393 let mut out: Vec<(String, PreviewStyle)> = Vec::new();
1394 let mut i = 0;
1395 let mut buf = String::new();
1396
1397 let flush = |buf: &mut String, out: &mut Vec<(String, PreviewStyle)>| {
1398 if !buf.is_empty() {
1399 let decoded = decode_entities(buf);
1400 out.push((decoded, PreviewStyle::Normal));
1401 buf.clear();
1402 }
1403 };
1404
1405 while i < chars.len() {
1406 if chars[i] == '\\' && i + 1 < chars.len() {
1408 let n = chars[i + 1];
1409 if "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".contains(n) {
1410 buf.push(n);
1411 i += 2;
1412 continue;
1413 }
1414 }
1415
1416 if chars[i] == '`' {
1418 let open_n = count_run(&chars, i, '`');
1419 if open_n >= 1 && open_n <= 3 {
1420 if let Some((inner, next)) = take_code_span(&chars, i, open_n) {
1421 flush(&mut buf, &mut out);
1422 out.push((inner, PreviewStyle::Code));
1423 i = next;
1424 continue;
1425 }
1426 }
1427 buf.push(chars[i]);
1428 i += 1;
1429 continue;
1430 }
1431
1432 if chars[i] == '<' {
1434 if let Some((kind, inner, next)) = take_simple_html(&chars, i) {
1435 flush(&mut buf, &mut out);
1436 match kind {
1437 "kbd" => out.push((inner, PreviewStyle::Kbd)),
1438 "br" => out.push(("\n".into(), PreviewStyle::Normal)),
1439 "em" | "i" => out.push((inner, PreviewStyle::Italic)),
1440 "strong" | "b" => out.push((inner, PreviewStyle::Bold)),
1441 "code" => out.push((inner, PreviewStyle::Code)),
1442 "del" | "s" => out.push((inner, PreviewStyle::Strike)),
1443 _ => out.push((inner, PreviewStyle::Html)),
1444 }
1445 i = next;
1446 continue;
1447 }
1448 if let Some((url, next)) = take_angle_autolink(&chars, i) {
1450 flush(&mut buf, &mut out);
1451 out.push((url, PreviewStyle::Link));
1452 i = next;
1453 continue;
1454 }
1455 }
1456
1457 if chars[i] == '!' && chars.get(i + 1) == Some(&'[') {
1459 if let Some((alt, url, next)) = parse_md_image(&chars, i) {
1460 flush(&mut buf, &mut out);
1461 let label = if alt.is_empty() {
1462 format!("🖼 {url}")
1463 } else {
1464 format!("🖼 {alt}")
1465 };
1466 out.push((label, PreviewStyle::Image));
1467 i = next;
1468 continue;
1469 }
1470 }
1471 if chars[i] == '[' {
1472 if chars.get(i + 1) == Some(&'^') {
1474 if let Some((id, next)) = take_footnote_ref(&chars, i) {
1475 flush(&mut buf, &mut out);
1476 out.push((format!("[^{id}]"), PreviewStyle::Footnote));
1477 i = next;
1478 continue;
1479 }
1480 }
1481 if let Some((label, _url, next)) = parse_md_link(&chars, i) {
1482 flush(&mut buf, &mut out);
1483 let mut inner = inline_md(&label);
1485 for s in &mut inner {
1486 if s.1 == PreviewStyle::Normal {
1487 s.1 = PreviewStyle::Link;
1488 }
1489 }
1490 if inner.is_empty() {
1491 out.push(("↗".into(), PreviewStyle::Link));
1492 } else {
1493 if let Some(last) = inner.last_mut() {
1495 last.0.push_str(" ↗");
1496 }
1497 out.extend(inner);
1498 }
1499 i = next;
1500 continue;
1501 }
1502 }
1503
1504 if (chars[i] == 'h' || chars[i] == 'H') && looks_like_url_start(&chars, i) {
1506 if let Some((url, next)) = take_bare_url(&chars, i) {
1507 flush(&mut buf, &mut out);
1508 out.push((url, PreviewStyle::Link));
1509 i = next;
1510 continue;
1511 }
1512 }
1513
1514 if chars[i] == '~' && chars.get(i + 1) == Some(&'~') {
1516 if let Some((inner, next)) = take_delimited(&chars, i, "~~", "~~") {
1517 if !inner.is_empty() {
1518 flush(&mut buf, &mut out);
1519 let mut nested = inline_md(&inner);
1521 for s in &mut nested {
1522 if s.1 == PreviewStyle::Normal {
1523 s.1 = PreviewStyle::Strike;
1524 }
1525 }
1526 out.extend(nested);
1527 i = next;
1528 continue;
1529 }
1530 }
1531 }
1532
1533 if chars[i] == '=' && chars.get(i + 1) == Some(&'=') {
1535 if let Some((inner, next)) = take_delimited(&chars, i, "==", "==") {
1536 if !inner.is_empty() {
1537 flush(&mut buf, &mut out);
1538 out.push((inner, PreviewStyle::AlertTip)); i = next;
1540 continue;
1541 }
1542 }
1543 }
1544
1545 if (chars[i] == '*' || chars[i] == '_')
1547 && chars.get(i + 1) == Some(&chars[i])
1548 && chars.get(i + 2) == Some(&chars[i])
1549 {
1550 let mark = chars[i];
1551 let open = format!("{mark}{mark}{mark}");
1552 if let Some((inner, next)) = take_delimited(&chars, i, &open, &open) {
1553 if !inner.is_empty() {
1554 flush(&mut buf, &mut out);
1555 out.push((inner, PreviewStyle::BoldItalic));
1556 i = next;
1557 continue;
1558 }
1559 }
1560 }
1561
1562 if (chars[i] == '*' || chars[i] == '_') && chars.get(i + 1) == Some(&chars[i]) {
1564 let mark = chars[i];
1565 if chars.get(i + 2) != Some(&mark) {
1566 let open = format!("{mark}{mark}");
1567 if let Some((inner, next)) = take_delimited(&chars, i, &open, &open) {
1568 if !inner.is_empty() {
1569 flush(&mut buf, &mut out);
1570 let mut nested = inline_md(&inner);
1571 for s in &mut nested {
1572 if s.1 == PreviewStyle::Normal {
1573 s.1 = PreviewStyle::Bold;
1574 } else if s.1 == PreviewStyle::Italic {
1575 s.1 = PreviewStyle::BoldItalic;
1576 }
1577 }
1578 out.extend(nested);
1579 i = next;
1580 continue;
1581 }
1582 }
1583 }
1584 }
1585
1586 if chars[i] == '*' || chars[i] == '_' {
1588 let mark = chars[i];
1589 let left_ok = mark == '*' || i == 0 || !chars[i - 1].is_alphanumeric();
1590 if left_ok {
1591 if let Some((inner, next)) = take_single_emphasis(&chars, i, mark) {
1592 if !inner.is_empty() {
1593 flush(&mut buf, &mut out);
1594 let mut nested = inline_md(&inner);
1595 for s in &mut nested {
1596 if s.1 == PreviewStyle::Normal {
1597 s.1 = PreviewStyle::Italic;
1598 } else if s.1 == PreviewStyle::Bold {
1599 s.1 = PreviewStyle::BoldItalic;
1600 }
1601 }
1602 out.extend(nested);
1603 i = next;
1604 continue;
1605 }
1606 }
1607 }
1608 }
1609
1610 buf.push(chars[i]);
1611 i += 1;
1612 }
1613 flush(&mut buf, &mut out);
1614 if out.is_empty() {
1615 out.push((String::new(), PreviewStyle::Normal));
1616 }
1617 out
1618}
1619
1620fn count_run(chars: &[char], start: usize, c: char) -> usize {
1621 let mut n = 0;
1622 while start + n < chars.len() && chars[start + n] == c {
1623 n += 1;
1624 }
1625 n
1626}
1627
1628fn take_code_span(chars: &[char], start: usize, open_n: usize) -> Option<(String, usize)> {
1629 let mut i = start + open_n;
1630 let mut inner = String::new();
1631 while i < chars.len() {
1632 if chars[i] == '`' {
1633 let close_n = count_run(chars, i, '`');
1634 if close_n == open_n {
1635 let trimmed = if inner.len() >= 2
1636 && inner.starts_with(' ')
1637 && inner.ends_with(' ')
1638 && !inner[1..inner.len() - 1].trim().is_empty()
1639 {
1640 inner[1..inner.len() - 1].to_string()
1641 } else {
1642 inner
1643 };
1644 return Some((trimmed, i + close_n));
1645 }
1646 for _ in 0..close_n {
1647 inner.push('`');
1648 }
1649 i += close_n;
1650 continue;
1651 }
1652 if chars[i] == '\n' {
1653 return None;
1654 }
1655 inner.push(chars[i]);
1656 i += 1;
1657 }
1658 None
1659}
1660
1661fn take_delimited(chars: &[char], start: usize, open: &str, close: &str) -> Option<(String, usize)> {
1662 let open_chars: Vec<char> = open.chars().collect();
1663 let close_chars: Vec<char> = close.chars().collect();
1664 if open_chars.is_empty() || !chars[start..].starts_with(&open_chars) {
1665 return None;
1666 }
1667 let mut i = start + open_chars.len();
1668 let mut inner = String::new();
1669 while i + close_chars.len() <= chars.len() {
1670 if chars[i..].starts_with(&close_chars) {
1671 return Some((inner, i + close_chars.len()));
1672 }
1673 if chars[i] == '\n' {
1674 return None;
1675 }
1676 inner.push(chars[i]);
1677 i += 1;
1678 }
1679 None
1680}
1681
1682fn take_single_emphasis(chars: &[char], start: usize, mark: char) -> Option<(String, usize)> {
1683 if chars.get(start + 1) == Some(&mark) {
1684 return None;
1685 }
1686 if chars.get(start + 1).is_some_and(|c| c.is_whitespace()) {
1688 return None;
1689 }
1690 let mut i = start + 1;
1691 let mut inner = String::new();
1692 while i < chars.len() {
1693 if chars[i] == mark {
1694 if chars.get(i + 1) == Some(&mark) {
1695 return None;
1696 }
1697 if mark == '_' {
1698 let after = chars.get(i + 1);
1699 if after.is_some_and(|c| c.is_alphanumeric()) {
1700 inner.push(chars[i]);
1701 i += 1;
1702 continue;
1703 }
1704 }
1705 if inner.ends_with(|c: char| c.is_whitespace()) || inner.is_empty() {
1707 return None;
1708 }
1709 return Some((inner, i + 1));
1710 }
1711 if chars[i] == '\n' {
1712 return None;
1713 }
1714 inner.push(chars[i]);
1715 i += 1;
1716 }
1717 None
1718}
1719
1720fn parse_md_link(chars: &[char], start: usize) -> Option<(String, String, usize)> {
1721 if chars.get(start) != Some(&'[') {
1722 return None;
1723 }
1724 let mut i = start + 1;
1725 let mut label = String::new();
1726 let mut depth = 1;
1727 while i < chars.len() {
1728 if chars[i] == '\\' && i + 1 < chars.len() {
1729 label.push(chars[i + 1]);
1730 i += 2;
1731 continue;
1732 }
1733 if chars[i] == '[' {
1734 depth += 1;
1735 label.push('[');
1736 i += 1;
1737 continue;
1738 }
1739 if chars[i] == ']' {
1740 depth -= 1;
1741 if depth == 0 {
1742 break;
1743 }
1744 label.push(']');
1745 i += 1;
1746 continue;
1747 }
1748 if chars[i] == '\n' {
1749 return None;
1750 }
1751 label.push(chars[i]);
1752 i += 1;
1753 }
1754 if i >= chars.len() || chars.get(i) != Some(&']') {
1755 return None;
1756 }
1757 i += 1; if chars.get(i) != Some(&'(') {
1759 if chars.get(i) == Some(&'[') {
1761 let mut j = i + 1;
1762 while j < chars.len() && chars[j] != ']' {
1763 j += 1;
1764 }
1765 if j < chars.len() {
1766 return Some((label, String::new(), j + 1));
1767 }
1768 }
1769 return None;
1770 }
1771 i += 1; let mut url = String::new();
1773 let angle = chars.get(i) == Some(&'<');
1775 if angle {
1776 i += 1;
1777 }
1778 while i < chars.len() {
1779 if angle && chars[i] == '>' {
1780 i += 1;
1781 break;
1782 }
1783 if !angle && (chars[i] == ')' || chars[i].is_whitespace()) {
1784 break;
1785 }
1786 if chars[i] == '\\' && i + 1 < chars.len() {
1787 url.push(chars[i + 1]);
1788 i += 2;
1789 continue;
1790 }
1791 url.push(chars[i]);
1792 i += 1;
1793 }
1794 while i < chars.len() && chars[i].is_whitespace() {
1796 i += 1;
1797 }
1798 if matches!(chars.get(i), Some('"' | '\'')) {
1799 let q = chars[i];
1800 i += 1;
1801 while i < chars.len() && chars[i] != q {
1802 i += 1;
1803 }
1804 if i < chars.len() {
1805 i += 1;
1806 }
1807 }
1808 while i < chars.len() && chars[i].is_whitespace() {
1809 i += 1;
1810 }
1811 if chars.get(i) != Some(&')') {
1812 return None;
1813 }
1814 Some((label, url, i + 1))
1815}
1816
1817fn parse_md_image(chars: &[char], start: usize) -> Option<(String, String, usize)> {
1818 if chars.get(start) != Some(&'!') {
1819 return None;
1820 }
1821 let (alt, url, next) = parse_md_link(chars, start + 1)?;
1822 Some((alt, url, next))
1823}
1824
1825fn take_footnote_ref(chars: &[char], start: usize) -> Option<(String, usize)> {
1826 if chars.get(start) != Some(&'[') || chars.get(start + 1) != Some(&'^') {
1828 return None;
1829 }
1830 let mut i = start + 2;
1831 let mut id = String::new();
1832 while i < chars.len() && chars[i] != ']' {
1833 if chars[i].is_whitespace() {
1834 return None;
1835 }
1836 id.push(chars[i]);
1837 i += 1;
1838 }
1839 if id.is_empty() || chars.get(i) != Some(&']') {
1840 return None;
1841 }
1842 if chars.get(i + 1) == Some(&':') {
1844 return None;
1845 }
1846 Some((id, i + 1))
1847}
1848
1849fn take_angle_autolink(chars: &[char], start: usize) -> Option<(String, usize)> {
1850 if chars.get(start) != Some(&'<') {
1851 return None;
1852 }
1853 let mut i = start + 1;
1854 let mut url = String::new();
1855 while i < chars.len() && chars[i] != '>' {
1856 if chars[i].is_whitespace() || chars[i] == '<' {
1857 return None;
1858 }
1859 url.push(chars[i]);
1860 i += 1;
1861 }
1862 if chars.get(i) != Some(&'>') || url.is_empty() {
1863 return None;
1864 }
1865 let ok = url.contains("://")
1867 || url.contains('@')
1868 || url.starts_with("www.");
1869 if !ok {
1870 return None;
1871 }
1872 Some((url, i + 1))
1873}
1874
1875fn looks_like_url_start(chars: &[char], i: usize) -> bool {
1876 let s: String = chars[i..].iter().take(8).collect();
1877 let lower = s.to_ascii_lowercase();
1878 lower.starts_with("https://") || lower.starts_with("http://")
1879}
1880
1881fn take_bare_url(chars: &[char], start: usize) -> Option<(String, usize)> {
1882 if !looks_like_url_start(chars, start) {
1883 return None;
1884 }
1885 let mut i = start;
1886 let mut url = String::new();
1887 while i < chars.len() {
1888 let c = chars[i];
1889 if c.is_whitespace() || "<>\"'`".contains(c) {
1890 break;
1891 }
1892 url.push(c);
1894 i += 1;
1895 }
1896 while url
1898 .chars()
1899 .last()
1900 .is_some_and(|c| ".,;:!?)]}".contains(c))
1901 {
1902 url.pop();
1903 i -= 1;
1904 }
1905 if url.len() < 8 {
1906 return None;
1907 }
1908 Some((url, i))
1909}
1910
1911fn take_simple_html(chars: &[char], start: usize) -> Option<(&'static str, String, usize)> {
1912 if chars.get(start) != Some(&'<') {
1913 return None;
1914 }
1915 let rest: String = chars[start..].iter().take(8).collect();
1917 let lower = rest.to_ascii_lowercase();
1918 if lower.starts_with("<br>") || lower.starts_with("<br/>") || lower.starts_with("<br />") {
1919 let n = if lower.starts_with("<br />") {
1920 6
1921 } else if lower.starts_with("<br/>") {
1922 5
1923 } else {
1924 4
1925 };
1926 return Some(("br", String::new(), start + n));
1927 }
1928 let mut i = start + 1;
1930 let mut tag = String::new();
1931 while i < chars.len() && chars[i].is_ascii_alphabetic() {
1932 tag.push(chars[i]);
1933 i += 1;
1934 }
1935 if tag.is_empty() || chars.get(i) != Some(&'>') {
1936 return None;
1937 }
1938 i += 1;
1939 let tag_l = tag.to_ascii_lowercase();
1940 let kind: &'static str = match tag_l.as_str() {
1941 "kbd" => "kbd",
1942 "em" => "em",
1943 "i" => "i",
1944 "strong" => "strong",
1945 "b" => "b",
1946 "code" => "code",
1947 "del" => "del",
1948 "s" => "s",
1949 "sub" | "sup" | "mark" | "span" => "html",
1950 _ => return None,
1951 };
1952 let close = format!("</{tag_l}>");
1953 let close_chars: Vec<char> = close.chars().collect();
1954 let mut inner = String::new();
1955 while i + close_chars.len() <= chars.len() {
1956 if chars[i..].starts_with(&close_chars)
1957 || chars[i..]
1958 .iter()
1959 .take(close_chars.len())
1960 .map(|c| c.to_ascii_lowercase())
1961 .eq(close_chars.iter().copied())
1962 {
1963 let mut j = 0;
1965 let mut ok = true;
1966 while j < close_chars.len() {
1967 if chars[i + j].to_ascii_lowercase() != close_chars[j] {
1968 ok = false;
1969 break;
1970 }
1971 j += 1;
1972 }
1973 if ok {
1974 return Some((kind, decode_entities(&inner), i + close_chars.len()));
1975 }
1976 }
1977 if chars[i] == '<' {
1978 return None;
1980 }
1981 inner.push(chars[i]);
1982 i += 1;
1983 }
1984 None
1985}
1986
1987fn decode_entities(s: &str) -> String {
1988 let mut out = String::with_capacity(s.len());
1989 let chars: Vec<char> = s.chars().collect();
1990 let mut i = 0;
1991 while i < chars.len() {
1992 if chars[i] == '&' {
1993 if let Some((decoded, next)) = take_entity(&chars, i) {
1994 out.push_str(&decoded);
1995 i = next;
1996 continue;
1997 }
1998 }
1999 out.push(chars[i]);
2000 i += 1;
2001 }
2002 out
2003}
2004
2005fn take_entity(chars: &[char], start: usize) -> Option<(String, usize)> {
2006 if chars.get(start) != Some(&'&') {
2007 return None;
2008 }
2009 if chars.get(start + 1) == Some(&'#') {
2011 let hex = chars.get(start + 2) == Some(&'x') || chars.get(start + 2) == Some(&'X');
2012 let mut i = start + if hex { 3 } else { 2 };
2013 let mut num = String::new();
2014 while i < chars.len() && chars[i] != ';' && num.len() < 8 {
2015 let c = chars[i];
2016 if hex && c.is_ascii_hexdigit() || !hex && c.is_ascii_digit() {
2017 num.push(c);
2018 i += 1;
2019 } else {
2020 break;
2021 }
2022 }
2023 if chars.get(i) != Some(&';') || num.is_empty() {
2024 return None;
2025 }
2026 let cp = if hex {
2027 u32::from_str_radix(&num, 16).ok()?
2028 } else {
2029 num.parse().ok()?
2030 };
2031 let ch = char::from_u32(cp)?;
2032 return Some((ch.to_string(), i + 1));
2033 }
2034 let mut i = start + 1;
2036 let mut name = String::new();
2037 while i < chars.len() && chars[i].is_ascii_alphanumeric() && name.len() < 16 {
2038 name.push(chars[i]);
2039 i += 1;
2040 }
2041 if chars.get(i) != Some(&';') {
2042 return None;
2043 }
2044 let decoded = match name.as_str() {
2045 "amp" => "&",
2046 "lt" => "<",
2047 "gt" => ">",
2048 "quot" => "\"",
2049 "apos" | "39" => "'",
2050 "nbsp" => " ",
2051 "mdash" => "—",
2052 "ndash" => "–",
2053 "hellip" => "…",
2054 "copy" => "©",
2055 "reg" => "®",
2056 "trade" => "™",
2057 "bull" => "•",
2058 "middot" => "·",
2059 "laquo" => "«",
2060 "raquo" => "»",
2061 "lsquo" => "\u{2018}",
2062 "rsquo" => "\u{2019}",
2063 "ldquo" => "\u{201C}",
2064 "rdquo" => "\u{201D}",
2065 "times" => "×",
2066 "divide" => "÷",
2067 "ne" => "≠",
2068 "le" => "≤",
2069 "ge" => "≥",
2070 "rarr" => "→",
2071 "larr" => "←",
2072 "uarr" => "↑",
2073 "darr" => "↓",
2074 "check" => "✓",
2075 "cross" => "✗",
2076 _ => return None,
2077 };
2078 Some((decoded.to_string(), i + 1))
2079}
2080
2081fn render_json(text: &str) -> Vec<PreviewLine> {
2084 let trimmed = text.trim();
2085 let pretty = pretty_json_heuristic(trimmed);
2086 let mut out = Vec::new();
2087 for line in pretty.lines() {
2088 out.push(pl(colorize_json_line(line)));
2089 }
2090 if out.is_empty() {
2091 out.push(pl(vec![("(empty)".into(), PreviewStyle::Dim)]));
2092 }
2093 out
2094}
2095
2096fn pretty_json_heuristic(s: &str) -> String {
2097 let mut out = String::new();
2098 let mut indent = 0i32;
2099 let mut in_str = false;
2100 let mut escape = false;
2101 let bytes: Vec<char> = s.chars().collect();
2102 let mut i = 0;
2103 while i < bytes.len() {
2104 let c = bytes[i];
2105 if in_str {
2106 out.push(c);
2107 if escape {
2108 escape = false;
2109 } else if c == '\\' {
2110 escape = true;
2111 } else if c == '"' {
2112 in_str = false;
2113 }
2114 i += 1;
2115 continue;
2116 }
2117 match c {
2118 '"' => {
2119 in_str = true;
2120 out.push(c);
2121 }
2122 '{' | '[' => {
2123 out.push(c);
2124 indent += 1;
2125 let mut j = i + 1;
2126 while j < bytes.len() && bytes[j].is_whitespace() {
2127 j += 1;
2128 }
2129 if j < bytes.len() && bytes[j] != '}' && bytes[j] != ']' {
2130 out.push('\n');
2131 out.push_str(&" ".repeat(indent as usize));
2132 }
2133 }
2134 '}' | ']' => {
2135 indent = (indent - 1).max(0);
2136 out.push('\n');
2137 out.push_str(&" ".repeat(indent as usize));
2138 out.push(c);
2139 }
2140 ',' => {
2141 out.push(c);
2142 out.push('\n');
2143 out.push_str(&" ".repeat(indent as usize));
2144 let mut j = i + 1;
2145 while j < bytes.len() && bytes[j].is_whitespace() {
2146 j += 1;
2147 }
2148 i = j;
2149 continue;
2150 }
2151 ':' => {
2152 out.push(c);
2153 out.push(' ');
2154 }
2155 c if c.is_whitespace() => {}
2156 c => out.push(c),
2157 }
2158 i += 1;
2159 }
2160 if out.is_empty() {
2161 s.to_string()
2162 } else {
2163 out
2164 }
2165}
2166
2167fn colorize_json_line(line: &str) -> Vec<(String, PreviewStyle)> {
2168 let t = line.trim_start();
2169 let indent = &line[..line.len() - t.len()];
2170 let mut spans = vec![(indent.to_string(), PreviewStyle::Normal)];
2171 if let Some(rest) = t.strip_prefix('"') {
2172 if let Some(end) = rest.find('"') {
2173 let key = format!("\"{}\"", &rest[..end]);
2174 let after = &rest[end + 1..];
2175 spans.push((key, PreviewStyle::JsonKey));
2176 if let Some(colon) = after.find(':') {
2177 spans.push((after[..=colon].to_string(), PreviewStyle::Dim));
2178 let val = after[colon + 1..].trim();
2179 spans.push((val.to_string(), json_val_style(val)));
2180 } else {
2181 spans.push((after.to_string(), PreviewStyle::JsonString));
2182 }
2183 return spans;
2184 }
2185 }
2186 spans.push((t.to_string(), json_val_style(t)));
2187 spans
2188}
2189
2190fn json_val_style(v: &str) -> PreviewStyle {
2191 let v = v.trim().trim_end_matches(',');
2192 if v.starts_with('"') {
2193 PreviewStyle::JsonString
2194 } else if v == "true" || v == "false" || v == "null" {
2195 PreviewStyle::JsonLit
2196 } else if v.chars().next().is_some_and(|c| c.is_ascii_digit() || c == '-') {
2197 PreviewStyle::JsonNumber
2198 } else {
2199 PreviewStyle::Normal
2200 }
2201}
2202
2203fn render_plain(text: &str) -> Vec<PreviewLine> {
2206 text.lines()
2207 .map(|l| pl(vec![(l.to_string(), PreviewStyle::Normal)]))
2208 .collect()
2209}
2210
2211fn pl(spans: Vec<(String, PreviewStyle)>) -> PreviewLine {
2212 PreviewLine { spans, image: None }
2213}
2214
2215fn mix(a: Color, b: Color, t: f32) -> Color {
2219 match (a, b) {
2220 (Color::Rgb(ar, ag, ab), Color::Rgb(br, bg, bb)) => Color::Rgb(
2221 (ar as f32 + (br as f32 - ar as f32) * t) as u8,
2222 (ag as f32 + (bg as f32 - ag as f32) * t) as u8,
2223 (ab as f32 + (bb as f32 - ab as f32) * t) as u8,
2224 ),
2225 _ => a,
2226 }
2227}
2228
2229pub fn to_ratatui_style(s: PreviewStyle, theme: &crate::theme::Theme) -> Style {
2232 let heading = |level: u8| {
2233 let t = (level.saturating_sub(1)) as f32 * 0.13;
2234 Style::default()
2235 .fg(mix(theme.accent, theme.fg, t))
2236 .add_modifier(Modifier::BOLD)
2237 };
2238 match s {
2239 PreviewStyle::Normal => Style::default().fg(theme.fg),
2240 PreviewStyle::H1 => heading(1),
2241 PreviewStyle::H2 => heading(2),
2242 PreviewStyle::H3 => heading(3),
2243 PreviewStyle::H4 => heading(4),
2244 PreviewStyle::H5 => heading(5),
2245 PreviewStyle::H6 => heading(6),
2246 PreviewStyle::Bold => Style::default()
2247 .fg(theme.fg)
2248 .add_modifier(Modifier::BOLD),
2249 PreviewStyle::Italic => Style::default()
2250 .fg(theme.fg)
2251 .add_modifier(Modifier::ITALIC),
2252 PreviewStyle::BoldItalic => Style::default()
2253 .fg(theme.fg)
2254 .add_modifier(Modifier::BOLD | Modifier::ITALIC),
2255 PreviewStyle::Strike => Style::default()
2256 .fg(theme.muted)
2257 .add_modifier(Modifier::CROSSED_OUT),
2258 PreviewStyle::Code => Style::default().fg(theme.number).bg(theme.panel_bg),
2259 PreviewStyle::CodeBlock => Style::default().fg(theme.string).bg(theme.panel_bg),
2260 PreviewStyle::CodeLang => Style::default()
2261 .fg(theme.namespace)
2262 .add_modifier(Modifier::BOLD),
2263 PreviewStyle::Link => Style::default()
2264 .fg(theme.accent)
2265 .add_modifier(Modifier::UNDERLINED),
2266 PreviewStyle::Image => Style::default()
2267 .fg(theme.macro_name)
2268 .add_modifier(Modifier::ITALIC),
2269 PreviewStyle::Quote => Style::default().fg(theme.comment),
2270 PreviewStyle::AlertNote => Style::default().fg(theme.accent),
2271 PreviewStyle::AlertTip => Style::default().fg(theme.success),
2272 PreviewStyle::AlertImportant => Style::default()
2273 .fg(theme.macro_name)
2274 .add_modifier(Modifier::BOLD),
2275 PreviewStyle::AlertWarning => Style::default()
2276 .fg(theme.warning)
2277 .add_modifier(Modifier::BOLD),
2278 PreviewStyle::AlertCaution => Style::default()
2279 .fg(theme.error)
2280 .add_modifier(Modifier::BOLD),
2281 PreviewStyle::ListBullet => Style::default().fg(theme.string),
2282 PreviewStyle::TaskDone => Style::default().fg(theme.success),
2283 PreviewStyle::TaskTodo => Style::default().fg(theme.muted),
2284 PreviewStyle::Hr => Style::default().fg(theme.border),
2285 PreviewStyle::Dim => Style::default().fg(theme.muted),
2286 PreviewStyle::Footnote => Style::default()
2287 .fg(theme.namespace)
2288 .add_modifier(Modifier::ITALIC),
2289 PreviewStyle::Kbd => Style::default()
2290 .fg(theme.fg)
2291 .bg(theme.selection_bg)
2292 .add_modifier(Modifier::BOLD),
2293 PreviewStyle::Html => Style::default().fg(theme.comment),
2294 PreviewStyle::JsonKey => Style::default().fg(theme.type_name),
2295 PreviewStyle::JsonString => Style::default().fg(theme.string),
2296 PreviewStyle::JsonNumber => Style::default().fg(theme.number),
2297 PreviewStyle::JsonLit => Style::default().fg(theme.macro_name),
2298 }
2299}
2300
2301pub fn preview_line_to_ratatui(line: &PreviewLine, theme: &crate::theme::Theme) -> Line<'static> {
2302 if line.spans.is_empty() {
2303 return Line::from(Span::raw(""));
2304 }
2305 let spans: Vec<Span<'static>> = line
2306 .spans
2307 .iter()
2308 .map(|(t, st)| Span::styled(t.clone(), to_ratatui_style(*st, theme)))
2309 .collect();
2310 Line::from(spans)
2311}
2312
2313#[cfg(test)]
2316mod tests {
2317 use super::*;
2318
2319 fn render_markdown_t(text: &str) -> Vec<PreviewLine> {
2320 render_markdown(text, None, (14, 28))
2321 }
2322
2323 fn has_style(lines: &[PreviewLine], text: &str, style: PreviewStyle) -> bool {
2324 lines.iter().any(|l| {
2325 l.spans
2326 .iter()
2327 .any(|(t, s)| t.contains(text) && *s == style)
2328 })
2329 }
2330
2331 #[test]
2332 fn md_heading_and_bold() {
2333 let lines = render_markdown_t("# Title\n\nHello **world** and `code`\n");
2334 assert!(has_style(&lines, "Title", PreviewStyle::H1));
2335 assert!(has_style(&lines, "world", PreviewStyle::Bold));
2336 assert!(has_style(&lines, "code", PreviewStyle::Code));
2337 }
2338
2339 #[test]
2340 fn md_list() {
2341 let lines = render_markdown_t("- one\n- two\n");
2342 assert!(lines.len() >= 2);
2343 assert!(lines[0]
2344 .spans
2345 .iter()
2346 .any(|(_, s)| *s == PreviewStyle::ListBullet));
2347 }
2348
2349 #[test]
2350 fn json_pretty() {
2351 let lines = render_json(r#"{"a":1,"b":"x"}"#);
2352 assert!(lines.len() > 1);
2353 }
2354
2355 #[test]
2356 fn kind_from_ext() {
2357 assert_eq!(PreviewKind::from_ext(Some("md")), PreviewKind::Markdown);
2358 assert_eq!(PreviewKind::from_ext(Some("json")), PreviewKind::Json);
2359 }
2360
2361 #[test]
2362 fn md_inline_quotes_and_apostrophe_stay_literal() {
2363 let spans = inline_md("it's fine, empty '' ok");
2364 let joined: String = spans.iter().map(|(t, _)| t.as_str()).collect();
2365 assert_eq!(joined, "it's fine, empty '' ok");
2366 assert!(spans.iter().all(|(_, s)| *s == PreviewStyle::Normal));
2367 }
2368
2369 #[test]
2370 fn md_inline_underscore_italic_and_bold() {
2371 let spans = inline_md("say _hi_ and __bye__");
2372 assert!(spans
2373 .iter()
2374 .any(|(t, s)| t == "hi" && *s == PreviewStyle::Italic));
2375 assert!(spans
2376 .iter()
2377 .any(|(t, s)| t == "bye" && *s == PreviewStyle::Bold));
2378 let spans2 = inline_md("snake_case");
2379 let joined: String = spans2.iter().map(|(t, _)| t.as_str()).collect();
2380 assert_eq!(joined, "snake_case");
2381 }
2382
2383 #[test]
2384 fn md_inline_strike_and_unmatched_star() {
2385 let spans = inline_md("~~gone~~ and lone * star");
2386 assert!(spans
2387 .iter()
2388 .any(|(t, s)| t == "gone" && *s == PreviewStyle::Strike));
2389 let joined: String = spans.iter().map(|(t, _)| t.as_str()).collect();
2390 assert!(joined.contains("lone * star"));
2391 }
2392
2393 #[test]
2394 fn md_code_with_inner_backticks() {
2395 let spans = inline_md("use `` `x` `` please");
2396 assert!(spans
2397 .iter()
2398 .any(|(t, s)| t.contains("`x`") && *s == PreviewStyle::Code));
2399 }
2400
2401 #[test]
2402 fn md_table_renders() {
2403 let md = "| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |\n";
2404 let lines = render_markdown_t(md);
2405 assert!(lines.len() >= 5, "got {} lines", lines.len());
2406 assert!(has_style(&lines, "Name", PreviewStyle::Bold));
2407 assert!(lines.iter().any(|l| {
2408 l.spans
2409 .iter()
2410 .any(|(t, _)| t.contains('┌') || t.contains('├') || t.contains('└'))
2411 }));
2412 }
2413
2414 #[test]
2415 fn md_task_list_and_nested() {
2416 let md = "- [x] done\n- [ ] todo\n - nested\n";
2417 let lines = render_markdown_t(md);
2418 assert!(has_style(&lines, "done", PreviewStyle::Strike) || has_style(&lines, "☑", PreviewStyle::TaskDone));
2419 assert!(lines.iter().any(|l| {
2420 l.spans
2421 .iter()
2422 .any(|(t, s)| t.contains("☐") && *s == PreviewStyle::TaskTodo)
2423 }));
2424 assert!(has_style(&lines, "nested", PreviewStyle::Normal) || lines.iter().any(|l| {
2425 l.spans.iter().any(|(t, _)| t.contains("nested"))
2426 }));
2427 }
2428
2429 #[test]
2430 fn md_alert_and_blockquote() {
2431 let md = "> [!WARNING]\n> be careful\n\n> plain quote\n";
2432 let lines = render_markdown_t(md);
2433 assert!(has_style(&lines, "WARNING", PreviewStyle::AlertWarning));
2434 assert!(lines.iter().any(|l| {
2435 l.spans
2436 .iter()
2437 .any(|(t, s)| t.contains("plain") && *s == PreviewStyle::Quote)
2438 }));
2439 }
2440
2441 #[test]
2442 fn md_autolink_image_entity_escape() {
2443 let spans = inline_md(r#"see https://example.com and  & \*star\*"#);
2444 assert!(spans.iter().any(|(t, s)| t.contains("example.com") && *s == PreviewStyle::Link));
2445 assert!(spans.iter().any(|(_, s)| *s == PreviewStyle::Image));
2446 let joined: String = spans.iter().map(|(t, _)| t.as_str()).collect();
2447 assert!(joined.contains('&'));
2448 assert!(joined.contains("*star*"));
2449 }
2450
2451 #[test]
2452 fn md_footnote() {
2453 let md = "Hello[^1]\n\n[^1]: World note\n";
2454 let lines = render_markdown_t(md);
2455 assert!(has_style(&lines, "[^1]", PreviewStyle::Footnote));
2456 assert!(lines.iter().any(|l| l.spans.iter().any(|(t, _)| t.contains("World"))));
2457 }
2458
2459 #[test]
2460 fn md_setext_and_h6_and_fence_tilde() {
2461 let md = "Main\n====\n\nSub\n----\n\n###### tiny\n\n~~~rs\nfn x(){}\n~~~\n";
2462 let lines = render_markdown_t(md);
2463 assert!(has_style(&lines, "Main", PreviewStyle::H1));
2464 assert!(has_style(&lines, "Sub", PreviewStyle::H2));
2465 assert!(has_style(&lines, "tiny", PreviewStyle::H6));
2466 assert!(has_style(&lines, "rs", PreviewStyle::CodeLang) || has_style(&lines, "fn x", PreviewStyle::CodeBlock));
2467 }
2468
2469 #[test]
2470 fn md_kbd_and_bold_italic() {
2471 let spans = inline_md("press <kbd>Ctrl</kbd> and ***both***");
2472 assert!(spans.iter().any(|(t, s)| t == "Ctrl" && *s == PreviewStyle::Kbd));
2473 assert!(spans
2474 .iter()
2475 .any(|(t, s)| t == "both" && *s == PreviewStyle::BoldItalic));
2476 }
2477
2478 #[test]
2479 fn anim_progress_lifecycle() {
2480 let mut p = PreviewState::new();
2481 assert!((p.anim_progress() - 1.0).abs() < f32::EPSILON || p.anim_progress() == 0.0);
2482 p.open_for("# t", Some("md"));
2483 assert_eq!(p.anim_progress(), 0.0);
2484 let a = p.anim_progress();
2485 assert!((0.0..=1.0).contains(&a));
2486 p.opened_at =
2487 Some(std::time::Instant::now() - std::time::Duration::from_millis(PREVIEW_ANIM_MS * 4));
2488 p.anim_from = 0.0;
2489 p.anim_to = 1.0;
2490 assert!((p.anim_progress() - 1.0).abs() < 0.01);
2491 p.close();
2492 assert!(p.closing);
2493 assert_eq!(p.anim_progress(), p.anim_from);
2494 assert!(p.open);
2495 p.opened_at =
2496 Some(std::time::Instant::now() - std::time::Duration::from_millis(PREVIEW_ANIM_MS * 4));
2497 p.anim_from = 1.0;
2498 p.anim_to = 0.0;
2499 let _ = p.anim_progress();
2500 assert!(!p.open);
2501 assert!(p.take_just_closed());
2502 }
2503
2504 #[test]
2505 fn is_table_sep_detects() {
2506 assert!(is_table_separator("|---|---|"));
2507 assert!(is_table_separator("|:--:|---:|"));
2508 assert!(!is_table_separator("| Name | Age |"));
2509 assert!(!is_table_separator("hello"));
2510 }
2511
2512 #[test]
2513 fn md_front_matter_skipped() {
2514 let md = "---\ntitle: x\n---\n# Hi\n";
2515 let lines = render_markdown_t(md);
2516 assert!(has_style(&lines, "Hi", PreviewStyle::H1));
2517 assert!(!lines.iter().any(|l| l.spans.iter().any(|(t, _)| t.contains("title:"))));
2518 }
2519}