Skip to main content

xei_core/
preview.rs

1//! Pretty document preview (GitHub-flavored Markdown, JSON, plain).
2//!
3//! TUI-oriented renderer — not a browser, but aims for readable GitHub-like
4//! output: GFM tables/tasks/alerts, nested lists & quotes, footnotes,
5//! autolinks, images, setext headings, fenced/indented code, entities, escapes.
6
7use 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    /// Cached rendered lines (rebuild on open / buffer change fingerprint)
61    pub lines: Vec<PreviewLine>,
62    pub source_len: usize,
63    /// Animation clock — armed by open/close, started by the **first render**
64    /// (see `anim_progress`), so synchronous open work can't eat the window.
65    pub opened_at: Option<std::time::Instant>,
66    pub anim_pending: bool,
67    /// Horizontal pan for long pretty lines (wrap_lines = false).
68    pub hscroll: usize,
69    /// Directory of the previewed file (relative image resolution).
70    pub base_dir: Option<std::path::PathBuf>,
71    /// (cell_w_px, cell_h_px) for image row budgeting.
72    pub cell_dims: (u32, u32),
73    /// Openness endpoints for the current phase (0 = source, 1 = fully pretty).
74    pub anim_from: f32,
75    pub anim_to: f32,
76    /// True while exit transform plays (`open` stays true until done).
77    pub closing: bool,
78    /// One-shot: close animation just finished.
79    pub just_closed: bool,
80    /// Media preview path (image / audio / npy) — not necessarily the buffer.
81    pub media_path: Option<std::path::PathBuf>,
82}
83
84/// Transform-reveal animation length (ms) — open and reverse-close.
85pub const PREVIEW_ANIM_MS: u64 = 420;
86
87/// Inline image anchored to a preview line (drawn by the Kitty layer).
88#[derive(Debug, Clone)]
89pub struct ImageBlock {
90    /// Resolved local file path.
91    pub path: String,
92    /// Rows reserved below the anchor for the picture.
93    pub rows: u16,
94    /// Cell-width budget the row count was computed for.
95    pub w_cells: u16,
96}
97
98#[derive(Debug, Clone)]
99pub struct PreviewLine {
100    pub spans: Vec<(String, PreviewStyle)>,
101    /// Set on an image anchor row (`![alt](local-file)` on its own line).
102    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    /// Instant hide (no reverse sweep).
183    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    /// Start reverse transform (pretty → source). Keeps `open` until done.
200    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    /// **Linear openness** 0.0..=1.0 (0 = source view, 1 = full pretty).
231    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    /// Open media / pretty file from a path (explorer or command).
287    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            // Image / Npy / Audio need a path — keep existing lines if any
388            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
409// ── Markdown (GFM-oriented) ─────────────────────────────
410
411
412/// Resolve a markdown image to a drawable local file + row budget.
413fn 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; // no network fetches — caption text only
416    }
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(); // "```" or "~~~"
453    let mut code_lang = String::new();
454    let mut footnotes: Vec<(String, String)> = Vec::new();
455
456    // Skip YAML front matter at file start
457    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        // subtle notice
467        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        // HTML comments
475        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        // Fenced code open/close: ``` or ~~~
491        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                    // close if same char and length >= open
495                    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                // still inside code
508                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                // strip info string extras (filename etc.) — first token is lang
516                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        // Footnote definition: [^id]: text
537        if let Some((id, body)) = parse_footnote_def(line) {
538            footnotes.push((id, body));
539            li += 1;
540            continue;
541        }
542
543        // Table
544        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        // Setext heading: text then === or ---
557        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        // HR (before list/heading misparse)
572        if is_hr(line) {
573            out.push(pl(vec![("─".repeat(56), PreviewStyle::Hr)]));
574            li += 1;
575            continue;
576        }
577
578        // ATX headings #..######
579        if let Some((level, title)) = parse_atx_heading(line) {
580            out.extend(heading_lines(level, &title));
581            li += 1;
582            continue;
583        }
584
585        // Indented code block (4 spaces or tab) — not a list continuation
586        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        // Blockquote / GitHub alert
610        if line.trim_start().starts_with('>') {
611            let (quote_lines, consumed) = collect_blockquote(&lines[li..]);
612            out.extend(render_blockquote(&quote_lines));
613            li += consumed;
614            continue;
615        }
616
617        // Lists (ul / ol / task), with nesting
618        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        // Empty
626        if line.trim().is_empty() {
627            out.push(pl(vec![("".into(), PreviewStyle::Normal)]));
628            li += 1;
629            continue;
630        }
631
632        // Standalone image line → real picture (Kitty layer) + caption.
633        {
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        // Paragraph: soft-join consecutive non-blank non-special lines (GFM-ish)
661        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    // Footnotes appendix
668    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; // GFM: fence indent ≤ 3
690    }
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    // for backticks, info string must not contain backtick
700    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    // require space or end after hashes (GFM)
757    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    // strip trailing hash closer: " ##"
762    while title.ends_with('#') {
763        let trimmed = title.trim_end_matches('#').trim_end();
764        if trimmed.len() == title.len() {
765            break;
766        }
767        // only strip if space before trailing hashes or all hashes
768        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    // prefix with level marker for hierarchy
799    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        // h3+: slight indent by level
813        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// ── Blockquote + GitHub alerts ──────────────────────────
846
847#[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    // [!NOTE] or [!NOTE] trailing text
887    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    // GitHub alert on first line
918    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// ── Lists ───────────────────────────────────────────────
984
985#[derive(Debug, Clone)]
986struct ListItem {
987    indent: usize,
988    ordered: bool,
989    number: u32,
990    task: Option<bool>, // Some(checked)
991    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    // only reasonable nest
1009    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    // unordered: - * +
1026    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    // ordered: 1. or 1)
1041    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            // blank: peek if next is still a list at same-or-deeper indent
1078            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            // continuation lines (indented more, not a new item)
1095            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
1161// ── Paragraph soft-join ─────────────────────────────────
1162
1163fn 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        // hard break: two trailing spaces → keep as separate visual line later?
1206        out.push(line.trim_end());
1207        i += 1;
1208        // setext underline would be consumed by earlier path; stop if next is underline
1209        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// ── Tables ──────────────────────────────────────────────
1220
1221#[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    // measure raw text width (without markdown markers ideally — use stripped approx)
1277    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    // rough: remove common markers for measurement
1342    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    // don't split on \| 
1365    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
1388// ── Inline markdown ─────────────────────────────────────
1389
1390/// GFM-oriented inline parse. Unmatched markers stay literal.
1391fn 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        // escape
1407        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        // code span
1417        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        // HTML <kbd>...</kbd>
1433        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            // autolink <https://...> or <email@x>
1449            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        // image ![alt](url) or link [text](url) or footnote [^id]
1458        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            // footnote ref [^id]
1473            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                // nested inline in label
1484                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                    // append link marker to last
1494                    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        // bare URL autolink
1505        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        // strikethrough
1515        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                    // allow nested inline inside strike
1520                    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        // highlight ==text== (GFM-ish extras used by some tools)
1534        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)); // soft highlight color
1539                    i = next;
1540                    continue;
1541                }
1542            }
1543        }
1544
1545        // *** bold italic *** or ___
1546        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        // ** bold ** or __
1563        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        // * italic * or _
1587        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    // opening content shouldn't be whitespace (GFM)
1687    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            // closing content shouldn't end with whitespace
1706            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; // ]
1758    if chars.get(i) != Some(&'(') {
1759        // reference link [text][id] — show label only
1760        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; // (
1772    let mut url = String::new();
1773    // optional <url>
1774    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    // skip title "..." or '...'
1795    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    // [^id]
1827    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    // definition is [^id]: — not a ref
1843    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    // must look like url or email
1866    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        // trailing punctuation often not part of URL
1893        url.push(c);
1894        i += 1;
1895    }
1896    // strip trailing .,;:!?)]
1897    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    // self-closing <br> <br/>
1916    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    // <tag>inner</tag>
1929    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            // case-insensitive match length
1964            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            // nested not supported
1979            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    // &#123; or &#x1F;
2010    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    // named
2035    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
2081// ── JSON ────────────────────────────────────────────────
2082
2083fn 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
2203// ── Plain ───────────────────────────────────────────────
2204
2205fn 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
2215// ── Style → ratatui ─────────────────────────────────────
2216
2217/// Blend two RGB colors (falls back to `a` for named colors).
2218fn 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
2229/// Theme-derived styling: headings grade from accent toward fg, semantic
2230/// colors come from the active theme so light themes stay readable.
2231pub 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// ── Tests ───────────────────────────────────────────────
2314
2315#[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 ![logo](a.png) &amp; \*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}