use ratatui_core::text::Line;
use crate::highlight::CodeHighlighter;
use crate::style::{StyleSheet, Theme};
use super::FencedBlockRenderer;
use super::flatten::flatten_into;
use super::image::{ImageResolver, MarkdownImage};
use super::item::MdItem;
use super::parse::parse_with;
fn stable_boundary(source: &str, from: usize) -> usize {
let mut fence_open = false;
let mut boundary = from;
let mut pos = from;
for line in source[from..].split_inclusive('\n') {
let trimmed = line.trim();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
fence_open = !fence_open;
} else if trimmed.is_empty() && !fence_open && line.ends_with('\n') {
boundary = pos + line.len();
}
pos += line.len();
}
boundary
}
#[derive(Default)]
pub struct MarkdownState {
source: String,
stable_len: usize,
stable: Vec<MdItem>,
cached_theme: Option<Theme>,
cached_sheet: Option<StyleSheet>,
rendered: Vec<Line<'static>>,
settled_lines: usize,
flattened_items: usize,
rendered_width: Option<u16>,
resolver: Option<Box<dyn ImageResolver>>,
block_renderer: Option<Box<dyn FencedBlockRenderer>>,
settled_images: Vec<MarkdownImage>,
frame_images: Vec<MarkdownImage>,
}
impl MarkdownState {
pub fn new() -> Self {
Self::default()
}
pub fn with_image_resolver(mut self, resolver: Box<dyn ImageResolver>) -> Self {
self.resolver = Some(resolver);
self.reset_cache();
self
}
pub fn with_block_renderer(mut self, renderer: Box<dyn FencedBlockRenderer>) -> Self {
self.block_renderer = Some(renderer);
self.reset_cache();
self
}
pub fn images(&self) -> &[MarkdownImage] {
&self.frame_images
}
pub fn push_str(&mut self, delta: &str) {
self.source.push_str(delta);
}
pub fn set(&mut self, source: impl Into<String>) {
self.source = source.into();
self.reset_cache();
}
pub fn source(&self) -> &str {
&self.source
}
fn reset_cache(&mut self) {
self.stable_len = 0;
self.stable.clear();
self.rendered.clear();
self.settled_lines = 0;
self.flattened_items = 0;
self.rendered_width = None;
self.settled_images.clear();
self.frame_images.clear();
}
pub fn lines(
&mut self,
width: u16,
theme: &Theme,
sheet: &StyleSheet,
highlighter: CodeHighlighter,
) -> &[Line<'static>] {
if self.cached_theme != Some(*theme) || self.cached_sheet != Some(*sheet) {
self.cached_theme = Some(*theme);
self.cached_sheet = Some(*sheet);
self.reset_cache();
}
if self.rendered_width != Some(width) {
self.rendered_width = Some(width);
self.rendered.clear();
self.settled_lines = 0;
self.flattened_items = 0;
self.settled_images.clear();
}
let boundary = stable_boundary(&self.source, self.stable_len);
if boundary > self.stable_len {
let segment = &self.source[self.stable_len..boundary];
let mut items =
parse_with(segment, theme, sheet, highlighter, self.resolver.as_deref());
if !items.is_empty()
&& !self.stable.is_empty()
&& !matches!(self.stable.last(), Some(MdItem::Blank))
{
self.stable.push(MdItem::Blank);
}
self.stable.append(&mut items);
self.stable_len = boundary;
}
self.rendered.truncate(self.settled_lines);
if self.flattened_items < self.stable.len() {
let base = self.rendered.len() as u16;
let mut settled_imgs = Vec::new();
let settled = flatten_into(
&self.stable[self.flattened_items..],
width,
theme,
self.block_renderer.as_deref(),
&mut settled_imgs,
);
for mut img in settled_imgs {
img.row = img.row.saturating_add(base);
self.settled_images.push(img);
}
self.rendered.extend(settled);
self.flattened_items = self.stable.len();
self.settled_lines = self.rendered.len();
}
let tail = parse_with(
&self.source[self.stable_len..],
theme,
sheet,
highlighter,
self.resolver.as_deref(),
);
let mut tail_imgs = Vec::new();
let tail_lines = flatten_into(
&tail,
width,
theme,
self.block_renderer.as_deref(),
&mut tail_imgs,
);
if !self.rendered.is_empty()
&& !tail_lines.is_empty()
&& !is_blank_line(self.rendered.last().unwrap())
&& !is_blank_line(&tail_lines[0])
{
self.rendered.push(Line::default());
}
let tail_base = self.rendered.len() as u16;
self.rendered.extend(tail_lines);
self.frame_images.clear();
self.frame_images
.extend(self.settled_images.iter().cloned());
for mut img in tail_imgs {
img.row = img.row.saturating_add(tail_base);
self.frame_images.push(img);
}
&self.rendered
}
}
fn is_blank_line(line: &Line) -> bool {
line.spans.iter().all(|s| s.content.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::super::testutil::*;
use super::super::to_lines;
use super::*;
use crate::style::StyleBundle;
use crate::highlight::CodeHighlighter;
use crate::style::{StyleSheet, Theme};
use ratatui_core::text::Line;
use std::cell::Cell;
use std::rc::Rc;
use super::super::FencedBlockRenderer;
#[test]
fn sheet_change_invalidates_stream_cache() {
use ratatui_core::style::Color;
let theme = Theme::default();
let mut state = MarkdownState::new();
state.set("A [link](https://ex.com) in prose.");
let default_sheet = StyleSheet::from_theme(&theme);
let link_fg = |lines: &[Line<'static>]| {
lines
.iter()
.flat_map(|l| &l.spans)
.find(|s| s.content.contains("link"))
.expect("link span")
.style
.fg
};
assert_eq!(
link_fg(state.lines(60, &theme, &default_sheet, CodeHighlighter::Plain)),
Some(theme.code.link)
);
let recolored = StyleSheet {
link: StyleBundle::new().fg(Color::Green),
..default_sheet
};
assert_eq!(
link_fg(state.lines(60, &theme, &recolored, CodeHighlighter::Plain)),
Some(Color::Green),
"a stylesheet change must invalidate the stream cache"
);
}
#[test]
fn streaming_matches_one_shot_render() {
let full = "# Heading\n\nA paragraph of text.\n\n```rust\nfn main() {}\n```\n\nDone.";
let theme = Theme::default();
let one_shot: Vec<String> = to_lines(
full,
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
)
.iter()
.map(text)
.collect();
let mut state = MarkdownState::new();
let mut streamed = Vec::new();
for chunk in [
"# Head",
"ing\n\nA para",
"graph of text.\n\n```rus",
"t\nfn main() {}\n```\n\nDone.",
] {
state.push_str(chunk);
streamed = state
.lines(
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
)
.iter()
.map(text)
.collect();
}
assert_eq!(streamed, one_shot);
}
#[test]
fn streaming_caches_settled_rendered_blocks_until_resize() {
struct CountingRenderer(Rc<Cell<usize>>);
impl FencedBlockRenderer for CountingRenderer {
fn render(
&self,
language: &str,
_source: &str,
width: u16,
_theme: &Theme,
) -> Option<Vec<Line<'static>>> {
(language == "diagram").then(|| {
self.0.set(self.0.get() + 1);
vec![Line::raw(format!("width {width}"))]
})
}
}
let calls = Rc::new(Cell::new(0));
let theme = Theme::default();
let sheet = StyleSheet::from_theme(&theme);
let mut state =
MarkdownState::new().with_block_renderer(Box::new(CountingRenderer(Rc::clone(&calls))));
state.set("```diagram\nA --> B\n```\n\n");
let _ = state.lines(40, &theme, &sheet, CodeHighlighter::Plain);
let _ = state.lines(40, &theme, &sheet, CodeHighlighter::Plain);
assert_eq!(calls.get(), 1, "settled block should stay flattened");
let _ = state.lines(20, &theme, &sheet, CodeHighlighter::Plain);
assert_eq!(calls.get(), 2, "resize must lay out the block again");
}
#[test]
fn streaming_an_indented_line_one_char_at_a_time_matches_one_shot() {
let full = "## Steps\n\n1. one\n - nested\n2. two\n";
let theme = Theme::default();
let sheet = StyleSheet::from_theme(&theme);
let one_shot: Vec<String> = to_lines(full, 40, &theme, &sheet, CodeHighlighter::Plain)
.iter()
.map(text)
.collect();
let mut state = MarkdownState::new();
for ch in full.chars() {
state.push_str(&ch.to_string());
let _ = state.lines(40, &theme, &sheet, CodeHighlighter::Plain);
}
let streamed: Vec<String> = state
.lines(40, &theme, &sheet, CodeHighlighter::Plain)
.iter()
.map(text)
.collect();
assert_eq!(streamed, one_shot);
}
#[test]
fn streaming_then_resize_matches_one_shot_at_new_width() {
let theme = Theme::default();
let chunks = [
"# A wide head",
"ing that wraps when narrow\n\nA para",
"graph long enough to wrap differently at 24 columns than at 60.\n\n",
"- a bullet that also wraps\n\nDone.",
];
let full: String = chunks.concat();
let mut state = MarkdownState::new();
for chunk in chunks {
state.push_str(chunk);
let _ = state.lines(
60,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
}
let resized: Vec<String> = state
.lines(
24,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
)
.iter()
.map(text)
.collect();
let one_shot: Vec<String> = to_lines(
&full,
24,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
)
.iter()
.map(text)
.collect();
assert_eq!(
resized, one_shot,
"resized stream must equal a one-shot render at the new width"
);
}
#[test]
fn streaming_commits_a_stable_prefix() {
let theme = Theme::default();
let mut state = MarkdownState::new();
state.push_str("First paragraph.\n\nSecond a");
let _ = state.lines(
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
assert!(state.stable_len > 0, "expected a committed prefix");
assert!(!state.stable.is_empty());
}
#[test]
fn stable_boundary_never_splits_open_code_fence() {
let src = "```\ncode\n\nmore code";
assert_eq!(stable_boundary(src, 0), 0);
let closed = "```\ncode\n```\n\nafter";
assert!(stable_boundary(closed, 0) > 0);
}
#[test]
fn theme_change_invalidates_cache() {
let mut state = MarkdownState::new();
state.push_str("Para one.\n\nPara two.\n\ntail");
let a = Theme::default();
let _ = state.lines(40, &a, &StyleSheet::from_theme(&a), CodeHighlighter::Plain);
assert!(state.stable_len > 0);
let mut b = Theme::default();
b.code.heading = ratatui_core::style::Color::Indexed(200);
let _ = state.lines(40, &b, &StyleSheet::from_theme(&b), CodeHighlighter::Plain);
assert_eq!(state.cached_theme, Some(b));
}
#[test]
fn streaming_state_reports_a_block_image_placement() {
let theme = Theme::default();
let mut md = MarkdownState::new().with_image_resolver(Box::new(StubResolver));
md.set("intro line\n\n\n\ntail line");
let lines = md
.lines(
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
)
.to_vec();
let imgs = md.images();
assert_eq!(imgs.len(), 1, "one block image reported");
let img = &imgs[0];
assert_eq!(img.alt, "a cat");
assert!((img.row as usize) < lines.len(), "row within lines");
assert!(
is_blank_line(&lines[img.row as usize]),
"reserved row is blank"
);
assert!(text(&lines[0]).contains("intro"));
}
#[test]
fn streaming_image_row_matches_one_shot() {
let theme = Theme::default();
let doc = "# Title\n\nbefore\n\n\n\nafter paragraph here";
let mut whole = MarkdownState::new().with_image_resolver(Box::new(StubResolver));
whole.set(doc);
let _ = whole.lines(
30,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
let whole_row = whole.images()[0].row;
let mut streamed = MarkdownState::new().with_image_resolver(Box::new(StubResolver));
for chunk in [
"# Title\n\nbe",
"fore\n\n\n\nafter ",
"paragraph here",
] {
streamed.push_str(chunk);
let _ = streamed.lines(
30,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
}
assert_eq!(streamed.images().len(), 1);
assert_eq!(
streamed.images()[0].row,
whole_row,
"streamed image row matches one-shot"
);
}
#[test]
fn no_resolver_means_no_streaming_placements() {
let theme = Theme::default();
let mut md = MarkdownState::new();
md.set("");
let _ = md.lines(
40,
&theme,
&StyleSheet::from_theme(&theme),
CodeHighlighter::Plain,
);
assert!(md.images().is_empty(), "images() empty without a resolver");
}
}