use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text};
use tui_markdown::{Options, StyleSheet};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const CODE_GUTTER_FG: Color = Color::Cyan;
const HEAD_FG: Color = Color::Cyan;
const TABLE_BORDER_FG: Color = Color::Rgb(90, 98, 120);
#[derive(Clone, Copy, Debug, Default)]
pub struct CodeStyle {
pub bg: Option<Color>,
pub label_bg: Option<Color>,
pub label_right: bool,
pub tab_width: usize,
}
#[derive(Clone, Copy, Debug, Default)]
struct KonomaStyles {
code_bg: Option<Color>,
}
impl StyleSheet for KonomaStyles {
fn heading(&self, level: u8) -> Style {
let base = Style::new().fg(HEAD_FG).add_modifier(Modifier::BOLD);
match level {
1 | 2 => base, 3 => base.add_modifier(Modifier::ITALIC),
_ => Style::new()
.fg(Color::Cyan)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
}
}
fn code(&self) -> Style {
let s = Style::new().fg(Color::White);
match self.code_bg {
Some(bg) => s.bg(bg),
None => s,
}
}
fn link(&self) -> Style {
Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED)
}
fn blockquote(&self) -> Style {
Style::new().fg(Color::Green).add_modifier(Modifier::ITALIC)
}
fn heading_meta(&self) -> Style {
Style::new().add_modifier(Modifier::DIM)
}
fn metadata_block(&self) -> Style {
Style::new().fg(Color::LightYellow)
}
}
pub fn render_markdown(src: &str, width: u16, code: CodeStyle, theme: &str) -> Vec<Line<'static>> {
let opts = Options::new(KonomaStyles { code_bg: code.bg });
let mut out = Vec::new();
for seg in split_segments(src) {
match seg {
Segment::Md(text) => {
if text.trim().is_empty() {
continue;
}
for part in split_tables(&text) {
match part {
MdPart::Text(t) => {
if t.trim().is_empty() {
continue;
}
let rendered = tui_markdown::from_str_with_options(&t, &opts);
let lines = into_static_lines(rendered);
out.extend(decorate_md_lines(lines, width, code, theme));
}
MdPart::Table(raw) => out.extend(render_table(&raw, width)),
}
}
}
Segment::Mermaid(code) => out.extend(render_mermaid_block(&code, width)),
}
}
out
}
fn decorate_md_lines(
lines: Vec<Line<'static>>,
width: u16,
code: CodeStyle,
theme: &str,
) -> Vec<Line<'static>> {
let lines = decorate_code_blocks(lines, width, code, theme);
decorate_headings(lines, width)
}
fn decorate_code_blocks(
lines: Vec<Line<'static>>,
width: u16,
code: CodeStyle,
theme: &str,
) -> Vec<Line<'static>> {
let w = width as usize;
let code_bg = code.bg;
let mut out = Vec::with_capacity(lines.len());
let mut in_code = false;
let mut lang = String::new();
let mut body: Vec<String> = Vec::new();
for line in lines {
let text = line.to_string();
let trimmed = text.trim_start();
if !in_code && trimmed.starts_with("```") {
in_code = true;
lang = trimmed.trim_matches('`').trim().to_string();
let label = if lang.is_empty() {
"code"
} else {
lang.as_str()
};
out.push(code_header(label, w, code));
body.clear();
continue;
}
if in_code && is_closing_fence(trimmed) {
in_code = false;
out.extend(highlight_body(
&body,
&lang,
w,
code_bg,
theme,
code.tab_width,
));
out.push(pad_to_width(vec![gutter_span(code_bg)], w, code_bg));
body.clear();
continue;
}
if in_code {
body.push(text);
continue;
}
out.push(line);
}
if in_code {
out.extend(highlight_body(
&body,
&lang,
w,
code_bg,
theme,
code.tab_width,
));
out.push(pad_to_width(vec![gutter_span(code_bg)], w, code_bg));
}
out
}
fn highlight_body(
body: &[String],
lang: &str,
w: usize,
code_bg: Option<Color>,
theme: &str,
tab_width: usize,
) -> Vec<Line<'static>> {
if body.is_empty() {
return Vec::new();
}
let src = body.join("\n");
let hl = crate::preview::code::expand_tabs(
crate::preview::code::highlight_lang(&src, lang, theme),
tab_width,
);
hl.into_iter()
.map(|line| {
let mut spans = vec![gutter_span(code_bg)];
for s in line.spans {
let st = match code_bg {
Some(bg) => s.style.bg(bg),
None => s.style,
};
spans.push(Span::styled(s.content, st));
}
pad_to_width(spans, w, code_bg)
})
.collect()
}
fn decorate_headings(lines: Vec<Line<'static>>, width: u16) -> Vec<Line<'static>> {
let w = width as usize;
let mut out = Vec::with_capacity(lines.len());
for line in lines {
if let Some(level) = heading_level(&line) {
let style = line.style;
let mut spans = line.spans;
spans.remove(0); out.push(Line::from(spans).style(style));
if level <= 2 {
let ch = if level == 1 { "━" } else { "─" };
out.push(Line::from(Span::styled(
ch.repeat(w),
Style::new().fg(HEAD_FG).add_modifier(Modifier::DIM),
)));
}
} else {
out.push(line);
}
}
out
}
fn heading_level(line: &Line) -> Option<u8> {
let content = line.spans.first()?.content.as_ref();
let hashes = content.strip_suffix(' ')?;
if !hashes.is_empty() && hashes.len() <= 6 && hashes.bytes().all(|b| b == b'#') {
Some(hashes.len() as u8)
} else {
None
}
}
fn gutter_span(code_bg: Option<Color>) -> Span<'static> {
let st = Style::new().fg(CODE_GUTTER_FG);
let st = match code_bg {
Some(bg) => st.bg(bg),
None => st,
};
Span::styled("▎ ", st)
}
fn code_header(label: &str, w: usize, code: CodeStyle) -> Line<'static> {
let code_bg = code.bg;
let gutter = gutter_span(code_bg);
let badge_text = format!(" {label} "); let badge_style = match code.label_bg {
Some(bg) => Style::new()
.fg(Color::White)
.bg(bg)
.add_modifier(Modifier::BOLD),
None => Style::new()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC | Modifier::DIM),
};
let gutter_w = gutter.width();
let badge_w = UnicodeWidthStr::width(badge_text.as_str());
let badge = Span::styled(badge_text, badge_style);
let fill_style = code_bg.map(|bg| Style::new().bg(bg)).unwrap_or_default();
let mut spans = vec![gutter];
if code.label_right && w > gutter_w + badge_w {
spans.push(Span::styled(" ".repeat(w - gutter_w - badge_w), fill_style));
spans.push(badge);
} else {
let used = gutter_w + badge_w;
spans.push(badge);
if w > used {
spans.push(Span::styled(" ".repeat(w - used), fill_style));
}
}
let line = Line::from(spans);
match code_bg {
Some(bg) => line.style(Style::new().bg(bg)),
None => line,
}
}
fn pad_to_width(mut spans: Vec<Span<'static>>, w: usize, code_bg: Option<Color>) -> Line<'static> {
let Some(bg) = code_bg else {
return Line::from(spans);
};
let used: usize = spans.iter().map(|s| s.width()).sum();
if used < w {
spans.push(Span::styled(" ".repeat(w - used), Style::new().bg(bg)));
}
Line::from(spans).style(Style::new().bg(bg))
}
fn is_closing_fence(trimmed: &str) -> bool {
let t = trimmed.trim_end();
t.len() >= 3 && t.bytes().all(|b| b == b'`')
}
pub fn render_mermaid_file(src: &str, width: u16) -> Vec<Line<'static>> {
render_mermaid_block(src, width)
}
fn render_mermaid_block(code: &str, width: u16) -> Vec<Line<'static>> {
let max_width = if width == 0 {
None
} else {
Some(width as usize)
};
match render_mermaid_safe(code.trim_end_matches('\n'), max_width) {
Ok(rendered) => rendered
.lines()
.map(|l| Line::from(l.to_string()))
.collect(),
Err(note) => fallback_raw(code, ¬e),
}
}
fn render_mermaid_safe(code: &str, max_width: Option<usize>) -> Result<String, String> {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mermaid_text::render_with_width(code, max_width)
}));
std::panic::set_hook(prev);
match caught {
Ok(Ok(s)) => Ok(s),
Ok(Err(e)) => Err(format!("cannot render mermaid: {e}")),
Err(_) => Err(
"cannot render mermaid (internal error: this diagram/char may be unsupported)"
.to_string(),
),
}
}
fn fallback_raw(code: &str, note: &str) -> Vec<Line<'static>> {
let mut v = vec![Line::from(Span::from(format!("[{note}]")).dim())];
for l in code.lines() {
v.push(Line::from(Span::from(format!(" {l}")).dim()));
}
v
}
fn into_static_lines(text: Text) -> Vec<Line<'static>> {
text.lines.into_iter().map(line_into_static).collect()
}
fn line_into_static(line: Line) -> Line<'static> {
let spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect();
let mut out = Line::from(spans).style(line.style);
if let Some(alignment) = line.alignment {
out = out.alignment(alignment);
}
out
}
#[derive(Debug, PartialEq)]
enum Segment {
Md(String),
Mermaid(String),
}
struct Fence {
ch: u8,
len: usize,
}
fn parse_fence(line: &str) -> Option<(Fence, String)> {
let trimmed = line.trim_start();
let ch = *trimmed.as_bytes().first()?;
if ch != b'`' && ch != b'~' {
return None;
}
let len = trimmed.bytes().take_while(|&b| b == ch).count();
if len < 3 {
return None;
}
let info = trimmed[len..].trim().to_string();
Some((Fence { ch, len }, info))
}
fn is_mermaid_info(info: &str) -> bool {
info.split_whitespace()
.next()
.is_some_and(|w| w.eq_ignore_ascii_case("mermaid"))
}
fn split_segments(src: &str) -> Vec<Segment> {
let mut segments = Vec::new();
let mut md = String::new();
let mut mermaid = String::new();
let mut open: Option<(Fence, bool)> = None;
for line in src.split_inclusive('\n') {
let bare = line.strip_suffix('\n').unwrap_or(line);
match &open {
None => {
if let Some((fence, info)) = parse_fence(bare) {
if is_mermaid_info(&info) {
if !md.is_empty() {
segments.push(Segment::Md(std::mem::take(&mut md)));
}
open = Some((fence, true));
} else {
md.push_str(line);
open = Some((fence, false));
}
} else {
md.push_str(line);
}
}
Some((fence, is_mermaid)) => {
let closing = parse_fence(bare)
.map(|(f, info)| f.ch == fence.ch && f.len >= fence.len && info.is_empty())
.unwrap_or(false);
if closing {
if *is_mermaid {
segments.push(Segment::Mermaid(std::mem::take(&mut mermaid)));
} else {
md.push_str(line); }
open = None;
} else if *is_mermaid {
mermaid.push_str(line);
} else {
md.push_str(line);
}
}
}
}
if !md.is_empty() {
segments.push(Segment::Md(md));
}
if let Some((_, true)) = open {
if !mermaid.is_empty() {
segments.push(Segment::Mermaid(mermaid));
}
}
segments
}
enum MdPart {
Text(String),
Table(String),
}
fn is_table_delimiter(line: &str) -> bool {
let t = line.trim();
if !t.contains('-') || !t.contains('|') {
return false;
}
t.chars().all(|c| matches!(c, ' ' | '\t' | '-' | ':' | '|'))
}
fn looks_like_table_row(line: &str) -> bool {
line.contains('|') && !line.trim().is_empty()
}
fn split_tables(md: &str) -> Vec<MdPart> {
let lines: Vec<&str> = md.lines().collect();
let mut parts = Vec::new();
let mut text = String::new();
let mut i = 0;
while i < lines.len() {
if i + 1 < lines.len() && looks_like_table_row(lines[i]) && is_table_delimiter(lines[i + 1])
{
if !text.is_empty() {
parts.push(MdPart::Text(std::mem::take(&mut text)));
}
let mut raw = String::new();
raw.push_str(lines[i]);
raw.push('\n');
raw.push_str(lines[i + 1]);
raw.push('\n');
let mut j = i + 2;
while j < lines.len() && looks_like_table_row(lines[j]) {
raw.push_str(lines[j]);
raw.push('\n');
j += 1;
}
parts.push(MdPart::Table(raw));
i = j;
} else {
text.push_str(lines[i]);
text.push('\n');
i += 1;
}
}
if !text.is_empty() {
parts.push(MdPart::Text(text));
}
parts
}
fn parse_table_row(line: &str) -> Vec<String> {
let t = line.trim();
let t = t.strip_prefix('|').unwrap_or(t);
let t = t.strip_suffix('|').unwrap_or(t);
t.split('|').map(|c| c.trim().to_string()).collect()
}
fn wrap_by_width(s: &str, w: usize) -> Vec<String> {
if w == 0 || UnicodeWidthStr::width(s) <= w {
return vec![s.to_string()];
}
let mut out = Vec::new();
let mut cur = String::new();
let mut cur_w = 0;
for ch in s.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
if cur_w + cw > w && !cur.is_empty() {
out.push(std::mem::take(&mut cur));
cur_w = 0;
}
cur.push(ch);
cur_w += cw;
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn render_table(raw: &str, width: u16) -> Vec<Line<'static>> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut header_rows = 0usize; for line in raw.lines() {
if is_table_delimiter(line) {
header_rows = rows.len();
continue;
}
rows.push(parse_table_row(line));
}
let ncol = rows.iter().map(|r| r.len()).max().unwrap_or(0);
if rows.is_empty() || ncol == 0 {
return Vec::new();
}
for r in &mut rows {
r.resize(ncol, String::new());
}
let mut col_w = vec![1usize; ncol];
for r in &rows {
for (c, cell) in r.iter().enumerate() {
col_w[c] = col_w[c].max(UnicodeWidthStr::width(cell.as_str()));
}
}
let frame = (ncol + 1) + 2 * ncol;
let budget = (width as usize).saturating_sub(frame).max(ncol);
let mut total: usize = col_w.iter().sum();
while total > budget {
let (mi, &mw) = col_w.iter().enumerate().max_by_key(|(_, &w)| w).unwrap();
if mw <= 1 {
break;
}
col_w[mi] -= 1;
total -= 1;
}
let border = Style::new().fg(TABLE_BORDER_FG);
let rule = |left: char, mid: char, right: char| -> Line<'static> {
let mut s = String::new();
s.push(left);
for (c, w) in col_w.iter().enumerate() {
for _ in 0..(w + 2) {
s.push('─');
}
s.push(if c + 1 == ncol { right } else { mid });
}
Line::from(Span::styled(s, border))
};
let mut out = Vec::new();
out.push(rule('┌', '┬', '┐'));
for (ri, r) in rows.iter().enumerate() {
let is_head = ri < header_rows;
let wrapped: Vec<Vec<String>> = r
.iter()
.enumerate()
.map(|(c, cell)| wrap_by_width(cell, col_w[c]))
.collect();
let phys = wrapped.iter().map(|w| w.len().max(1)).max().unwrap_or(1);
let cell_style = if is_head {
Style::new().fg(HEAD_FG).add_modifier(Modifier::BOLD)
} else {
Style::new()
};
for p in 0..phys {
let mut spans: Vec<Span<'static>> = vec![Span::styled("│", border)];
for c in 0..ncol {
let cell = wrapped[c].get(p).map(|s| s.as_str()).unwrap_or("");
let pad = col_w[c].saturating_sub(UnicodeWidthStr::width(cell));
let content = format!(" {cell}{} ", " ".repeat(pad));
spans.push(Span::styled(content, cell_style));
spans.push(Span::styled("│", border));
}
out.push(Line::from(spans));
}
if header_rows > 0 && ri + 1 == header_rows {
out.push(rule('├', '┼', '┤'));
}
}
out.push(rule('└', '┴', '┘'));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DEFAULT_CODE_BG;
const BG: CodeStyle = CodeStyle {
bg: Some(DEFAULT_CODE_BG),
label_bg: Some(Color::Rgb(70, 78, 99)), label_right: true,
tab_width: 4,
};
const NO_CODE: CodeStyle = CodeStyle {
bg: None,
label_bg: None,
label_right: true,
tab_width: 4,
};
fn line_disp_width(l: &Line<'_>) -> usize {
let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
UnicodeWidthStr::width(s.as_str())
}
#[test]
fn code_block_tabs_expand_to_marker() {
let md = "```ts\nfunction f() {\n\tconst x = 1;\n}\n```\n";
let lines = render_markdown(md, 40, BG, "TwoDark");
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let tab_line = texts
.iter()
.find(|t| t.contains("const x"))
.expect("コード行が無い");
assert!(
tab_line.contains('→'),
"タブが可視化されていない: {tab_line:?}"
);
assert!(!tab_line.contains('\t'), "生タブが残っている: {tab_line:?}");
assert!(
tab_line.starts_with("▎ →"),
"ガター+マーカーの並びが違う: {tab_line:?}"
);
let marker_lines = texts.iter().filter(|t| t.contains('→')).count();
assert_eq!(marker_lines, 1, "マーカー行数が想定外: {marker_lines}");
}
#[test]
fn cjk_table_is_rectangular_and_aligned() {
let md = "| 種別 | ライブラリ | 依存 |\n|------|------------|------|\n\
| md | tui-markdown | ratatui-core |\n| 図 | mermaid-text | unicode-width |\n";
let lines = render_markdown(md, 80, BG, "TwoDark");
assert!(
lines.len() >= 6,
"表が行に展開されていない: {}",
lines.len()
);
let w0 = line_disp_width(&lines[0]);
assert!(w0 > 0);
for (i, l) in lines.iter().enumerate() {
assert_eq!(line_disp_width(l), w0, "{i}行目の表示幅が不揃い(右枠ズレ)");
}
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|sp| sp.content.as_ref()))
.collect();
assert!(joined.contains('┌') && joined.contains('┼') && joined.contains('┘'));
}
#[test]
fn wide_table_wraps_within_terminal_width() {
let md = "| 名前 | 説明 |\n|---|---|\n\
| konoma | 全画面プレビュー特化のターミナルファイルブラウザです長い説明 |\n";
let lines = render_markdown(md, 30, BG, "TwoDark");
for (i, l) in lines.iter().enumerate() {
assert!(line_disp_width(l) <= 30, "{i}行目が幅30を超過");
}
let w0 = line_disp_width(&lines[0]);
assert!(lines.iter().all(|l| line_disp_width(l) == w0), "矩形でない");
}
#[test]
fn splits_mermaid_fence_out_of_markdown() {
let src = "# Title\n\nbefore\n\n```mermaid\ngraph TD\n A --> B\n```\n\nafter\n";
let segs = split_segments(src);
assert_eq!(segs.len(), 3, "got {segs:?}");
assert!(matches!(&segs[0], Segment::Md(s) if s.contains("Title")));
assert!(matches!(&segs[1], Segment::Mermaid(s) if s.contains("graph TD")));
assert!(matches!(&segs[2], Segment::Md(s) if s.contains("after")));
assert!(matches!(&segs[1], Segment::Mermaid(s) if !s.contains("```")));
}
#[test]
fn normal_code_fence_is_kept_in_markdown() {
let src = "text\n\n```rust\nlet x = 1;\n```\n";
let segs = split_segments(src);
assert_eq!(segs.len(), 1, "got {segs:?}");
assert!(matches!(&segs[0], Segment::Md(s) if s.contains("let x = 1;")));
}
#[test]
fn mermaid_inside_normal_fence_is_not_intercepted() {
let src = "~~~\n```mermaid\nnot a diagram\n```\n~~~\n";
let segs = split_segments(src);
assert!(
segs.iter().all(|s| matches!(s, Segment::Md(_))),
"got {segs:?}"
);
}
#[test]
fn renders_plain_markdown_to_lines() {
let lines = render_markdown("# Hello\n\nworld\n", 80, BG, "TwoDark");
assert!(!lines.is_empty());
}
#[test]
fn invalid_mermaid_falls_back_to_raw() {
let lines = render_mermaid_file("this is definitely not mermaid syntax", 80);
assert!(!lines.is_empty());
}
#[test]
fn cjk_sequence_diagram_renders_not_fallback() {
let src = "sequenceDiagram\n U->>K: ツリーで .mmd を選ぶ\n K-->>U: 全画面プレビュー";
let lines = render_mermaid_file(src, 70);
assert!(!lines.is_empty(), "CJK 入力でも行を返すこと");
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
!joined.contains("cannot render mermaid"),
"fallback に落ちている (patch 不在の疑い): {joined}"
);
assert!(
joined
.chars()
.any(|c| ('\u{2500}'..='\u{257F}').contains(&c)),
"CJK 図に罫線が無い (panic→fallback の疑い): {joined}"
);
}
#[test]
fn ascii_sequence_diagram_renders_box_drawing() {
let src = "sequenceDiagram\n participant U as User\n participant K as konoma\n U->>K: open\n K-->>U: preview";
let lines = render_mermaid_file(src, 70);
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
!joined.contains("cannot render mermaid"),
"fallback に落ちている: {joined}"
);
assert!(
joined
.chars()
.any(|c| ('\u{2500}'..='\u{257F}').contains(&c)),
"罫線が無い: {joined}"
);
}
#[test]
fn heading_hash_is_stripped_and_rule_added() {
let lines = render_markdown("# Title\n\nbody\n", 20, BG, "TwoDark");
assert_eq!(lines[0].to_string(), "Title");
assert!(
lines[1].to_string().chars().all(|c| c == '━'),
"rule 行が無い: {:?}",
lines[1].to_string()
);
}
#[test]
fn code_block_becomes_special_area() {
let lines = render_markdown("text\n\n```rust\nlet x = 1;\n```\n", 30, BG, "TwoDark");
let coded = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行が無い");
assert_eq!(
coded.style.bg,
Some(DEFAULT_CODE_BG),
"背景が敷かれていない"
);
assert!(coded.to_string().starts_with("▎"), "左ガターが無い");
assert!(lines.iter().all(|l| !l.to_string().contains("```")));
assert!(lines.iter().any(|l| l.to_string().contains("rust")));
}
#[test]
fn code_block_content_is_syntax_highlighted_and_indented() {
let lines = render_markdown(
"```rust\nfn f() {\n let x = 1;\n}\n```\n",
40,
BG,
"TwoDark",
);
let colored = lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _))));
assert!(colored, "md コードがハイライトされていない");
let indented = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行");
assert!(
indented.to_string().contains(" let x = 1;"),
"インデントが失われた: {:?}",
indented.to_string()
);
}
#[test]
fn code_header_language_is_a_right_aligned_badge() {
let lines = render_markdown("```rust\nlet x = 1;\n```\n", 28, BG, "TwoDark");
let header = lines
.iter()
.find(|l| l.to_string().contains("rust"))
.expect("言語ヘッダが無い");
assert!(
header.to_string().trim_end().ends_with("rust"),
"右寄せでない: {:?}",
header.to_string()
);
let badge = header
.spans
.iter()
.find(|s| s.content.contains("rust"))
.expect("バッジ span");
assert_eq!(
badge.style.bg,
Some(crate::config::lighten(DEFAULT_CODE_BG)),
"バッジ背景が明るくない"
);
assert_ne!(badge.style.bg, Some(DEFAULT_CODE_BG), "本文背景と同色");
}
#[test]
fn code_header_align_left_and_right() {
let right = render_markdown("```rust\nx\n```\n", 28, BG, "TwoDark");
let rh = right
.iter()
.find(|l| l.to_string().contains("rust"))
.unwrap();
let rs = rh.to_string();
assert!(rs.trim_end().ends_with("rust"), "右寄せでない: {rs:?}");
let right_pos = rs.find("rust").unwrap();
let left = render_markdown(
"```rust\nx\n```\n",
28,
CodeStyle {
label_right: false,
..BG
},
"TwoDark",
);
let ls = left
.iter()
.find(|l| l.to_string().contains("rust"))
.unwrap()
.to_string();
let left_pos = ls.find("rust").unwrap();
assert!(
left_pos < right_pos,
"左寄せが右寄せより前に来ていない: left={left_pos} right={right_pos}"
);
}
#[test]
fn code_label_bg_is_configurable() {
let style = CodeStyle {
label_bg: Some(Color::Rgb(200, 50, 50)),
..BG
};
let lines = render_markdown("```rust\nx\n```\n", 28, style, "TwoDark");
let badge = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.contains("rust"))
.expect("バッジ span");
assert_eq!(badge.style.bg, Some(Color::Rgb(200, 50, 50)));
}
#[test]
fn code_header_badge_has_no_bg_when_code_bg_none() {
let lines = render_markdown("```rust\nx\n```\n", 28, NO_CODE, "TwoDark");
let badge = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.contains("rust"))
.expect("バッジ span");
assert_eq!(badge.style.bg, None);
}
#[test]
fn code_bg_color_is_configurable() {
let green = Color::Rgb(10, 80, 20);
let md = "本文 `inline` 続き\n\n```rust\nlet x = 1;\n```\n";
let style = CodeStyle {
bg: Some(green),
..BG
};
let lines = render_markdown(md, 40, style, "TwoDark");
let inline_bg = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.as_ref() == "inline")
.and_then(|s| s.style.bg);
assert_eq!(inline_bg, Some(green), "inline code に設定色が乗っていない");
let coded = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行が無い");
assert_eq!(
coded.style.bg,
Some(green),
"コードブロックに設定色が乗っていない"
);
}
#[test]
fn code_bg_none_removes_all_backgrounds() {
let md = "本文 `inline` 続き\n\n```rust\nlet x = 1;\n```\n";
let lines = render_markdown(md, 40, NO_CODE, "TwoDark");
let inline_bg = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.as_ref() == "inline")
.and_then(|s| s.style.bg);
assert_eq!(inline_bg, None, "inline code の背景が消えていない");
let coded = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行が無い");
assert_eq!(coded.style.bg, None, "コードブロックの背景が消えていない");
assert!(coded.to_string().starts_with("▎"), "左ガターは残すべき");
}
#[test]
fn cjk_in_markdown_with_mermaid_fence_does_not_panic() {
let src =
"# 図\n\n```mermaid\nsequenceDiagram\n 甲->>乙: こんにちは\n 乙-->>甲: どうも\n```\n";
let lines = render_markdown(src, 70, BG, "TwoDark");
assert!(!lines.is_empty());
}
#[test]
fn konoma_stylesheet_arms_return_expected_styles() {
let s = KonomaStyles {
code_bg: Some(Color::Rgb(1, 2, 3)),
};
let bq = s.blockquote();
assert_eq!(bq.fg, Some(Color::Green));
assert!(bq.add_modifier.contains(Modifier::ITALIC));
assert_eq!(s.metadata_block().fg, Some(Color::LightYellow));
assert!(s.heading_meta().add_modifier.contains(Modifier::DIM));
assert_eq!(s.heading(1).fg, Some(HEAD_FG));
assert!(s.heading(1).add_modifier.contains(Modifier::BOLD));
assert!(s.heading(3).add_modifier.contains(Modifier::ITALIC));
assert!(s.heading(6).add_modifier.contains(Modifier::DIM));
assert_eq!(s.code().bg, Some(Color::Rgb(1, 2, 3)));
let no_bg = KonomaStyles { code_bg: None };
assert_eq!(no_bg.code().bg, None);
assert!(s.link().add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn render_markdown_with_mermaid_fence_renders_box_drawing() {
let md = "# Title\n\n```mermaid\nsequenceDiagram\n A->>B: hi\n B-->>A: yo\n```\n";
let lines = render_markdown(md, 70, BG, "TwoDark");
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
!joined.contains("cannot render mermaid"),
"fallback に落ちた: {joined}"
);
assert!(
joined
.chars()
.any(|c| ('\u{2500}'..='\u{257F}').contains(&c)),
"罫線が無い: {joined}"
);
}
}