use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
#[derive(Clone)]
struct Adaptive;
impl tui_markdown::StyleSheet for Adaptive {
fn heading(&self, level: u8) -> Style {
match level {
1 => Style::default()
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::UNDERLINED),
2 => Style::default().add_modifier(Modifier::BOLD),
_ => Style::default()
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::ITALIC),
}
}
fn code(&self) -> Style {
Style::default().fg(ratatui::style::Color::Cyan)
}
fn link(&self) -> Style {
Style::default().add_modifier(Modifier::UNDERLINED)
}
fn blockquote(&self) -> Style {
Style::default()
.fg(ratatui::style::Color::DarkGray)
.add_modifier(Modifier::ITALIC)
}
fn table_border(&self) -> Style {
Style::default().fg(ratatui::style::Color::DarkGray)
}
fn table_header(&self) -> Style {
Style::default().add_modifier(Modifier::BOLD)
}
}
pub fn render(text: &str) -> Vec<Line<'static>> {
let prepared = hard_break_soft_lines(text);
let options = tui_markdown::Options::new(Adaptive);
tui_markdown::from_str_with_options(&prepared, &options)
.lines
.into_iter()
.map(|line| {
let spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect();
Line::from(spans).style(line.style)
})
.collect()
}
fn starts_a_block(trimmed: &str) -> bool {
if trimmed.starts_with("```")
|| trimmed.starts_with('>')
|| trimmed.starts_with('#')
|| trimmed.starts_with('|')
{
return true;
}
for m in ["- ", "* ", "+ "] {
if trimmed.starts_with(m) {
return true;
}
}
let digits = trimmed.chars().take_while(char::is_ascii_digit).count();
digits > 0 && {
let rest = &trimmed[digits..];
rest.starts_with(". ") || rest.starts_with(") ")
}
}
fn preserve_indent(line: &str) -> String {
let trimmed = line.trim_start();
if trimmed.is_empty() || starts_a_block(trimmed) {
return line.to_string();
}
let indent_len = line.len() - trimmed.len();
if indent_len == 0 {
return line.to_string();
}
let cells: usize = line[..indent_len]
.chars()
.map(|c| if c == '\t' { 4 } else { 1 })
.sum();
let mut out = String::with_capacity(line.len() + cells);
out.push_str(&"\u{a0}".repeat(cells));
out.push_str(trimmed);
out
}
fn hard_break_soft_lines(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
let mut out = String::with_capacity(text.len() + lines.len() * 2);
let mut in_fence = false;
for (i, line) in lines.iter().enumerate() {
let is_fence = line.trim_start().starts_with("```");
if is_fence {
in_fence = !in_fence;
}
if in_fence || is_fence {
out.push_str(line);
} else {
out.push_str(&preserve_indent(line));
}
let next_is_text = lines
.get(i + 1)
.is_some_and(|next| !next.trim().is_empty());
let needs_break = !in_fence
&& !is_fence
&& !line.trim().is_empty()
&& next_is_text
&& !line.ends_with(" ");
if needs_break {
out.push_str(" ");
}
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn text_of(lines: &[Line<'_>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
#[test]
fn consecutive_lines_stay_separate() {
let out = text_of(&render("line one\nline two"));
assert!(
out.iter().any(|l| l.trim() == "line one"),
"lines were joined: {out:?}"
);
assert!(out.iter().any(|l| l.trim() == "line two"), "{out:?}");
}
#[test]
fn code_fences_are_never_touched() {
let src = "text\n```\nfn main() {\n let x = 1;\n}\n```\nmore";
let prepared = hard_break_soft_lines(src);
assert!(prepared.contains("fn main() {\n"), "code line was modified");
assert!(
prepared.contains(" let x = 1;\n"),
"indented code line was modified: {prepared:?}"
);
assert!(!prepared.contains("``` "), "fence delimiter was modified");
}
#[test]
fn blank_lines_are_left_alone() {
let prepared = hard_break_soft_lines("a\n\nb");
assert!(!prepared.contains("a \n\n"), "break added before a blank line");
}
#[test]
fn a_line_already_ending_in_a_hard_break_is_not_doubled() {
let prepared = hard_break_soft_lines("a \nb");
assert!(!prepared.contains("a \n"), "break was applied twice");
}
#[test]
fn tables_still_render_as_tables() {
let out = text_of(&render("| a | b |\n|---|---|\n| 1 | 2 |"));
let joined = out.join("\n");
assert!(joined.contains('┌') && joined.contains('┼'), "{joined}");
}
#[test]
fn no_input_text_is_lost() {
let src = "alpha\nbeta\n\n- item\n- another\n\n```\ncode\n```\ngamma";
let prepared = hard_break_soft_lines(src);
for word in ["alpha", "beta", "item", "another", "code", "gamma"] {
assert!(prepared.contains(word), "{word} went missing");
}
}
#[test]
fn line_count_is_preserved_by_the_preprocessing() {
let src = "a\nb\n\nc\n```\nd\n```\ne";
assert_eq!(
hard_break_soft_lines(src).lines().count(),
src.lines().count()
);
}
const NBSP: char = '\u{a0}';
#[test]
fn plain_indented_text_keeps_its_shape() {
let out = text_of(&render("Notes:\n indented once\n twice"));
let joined = out.join("\n");
assert!(
joined.contains(&format!("{}{}{}{}indented", NBSP, NBSP, NBSP, NBSP)),
"indentation was lost: {out:?}"
);
}
#[test]
fn four_spaces_no_longer_becomes_a_code_block() {
let prepared = hard_break_soft_lines("text\n four spaces");
assert!(!prepared.contains("\n four"), "leading spaces survived as-is");
}
#[test]
fn nested_list_indentation_is_left_structural() {
let prepared = hard_break_soft_lines("- a\n - nested\n - deeper");
assert!(prepared.contains("\n - nested"), "nesting was rewritten: {prepared:?}");
assert!(prepared.contains("\n - deeper"), "nesting was rewritten");
}
#[test]
fn indentation_inside_a_fence_is_untouched() {
let prepared = hard_break_soft_lines("```\n let x = 1;\n```");
assert!(prepared.contains("\n let x = 1;"), "code indent was rewritten");
}
#[test]
fn other_block_markers_keep_their_indentation() {
for line in [" > quoted", " # heading", " | a | b |", " 1. first"] {
let prepared = hard_break_soft_lines(line);
assert!(
prepared.starts_with(" "),
"{line:?} had structural indentation rewritten"
);
}
}
#[test]
fn a_tab_indent_is_preserved_as_width() {
let prepared = hard_break_soft_lines("text\n\tindented");
assert!(prepared.contains(NBSP), "tab indent was dropped");
assert!(!prepared.contains('\t'), "tab survived and would collapse");
}
#[test]
fn unindented_lines_are_not_touched() {
let prepared = hard_break_soft_lines("plain line\nanother");
assert!(!prepared.contains(NBSP));
}
}
#[cfg(test)]
mod style_checks {
use super::*;
use ratatui::style::Style;
fn no_bare_background(style: Style, what: &str) {
if style.bg.is_some() {
assert!(
style.fg.is_some(),
"{what} sets a background with no foreground: {style:?}"
);
}
}
#[test]
fn no_rendered_style_sets_a_background_without_a_foreground() {
let src = "# H1\n\n## H2\n\n### H3\n\ntext with `code` in it\n\n\
> a quote\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n\
[link](https://example.com)\n";
for line in render(src) {
no_bare_background(line.style, "line");
for span in &line.spans {
no_bare_background(span.style, &format!("span {:?}", span.content));
}
}
}
#[test]
fn headings_are_distinguished_without_relying_on_colour() {
use tui_markdown::StyleSheet;
let sheet = Adaptive;
for level in 1..=3u8 {
let s = sheet.heading(level);
assert!(
!s.add_modifier.is_empty(),
"heading {level} has no modifier to distinguish it: {s:?}"
);
assert!(s.bg.is_none(), "heading {level} sets a background");
}
}
#[test]
fn inline_code_has_no_fixed_black_block() {
use tui_markdown::StyleSheet;
assert!(Adaptive.code().bg.is_none());
}
}