use std::cell::RefCell;
use crate::core::buffer::{Buffer, Cell};
use crate::core::color::Color;
use crate::core::rect::Rect;
use crate::sanitize;
use crate::scroll_state::ScrollState;
use crate::theme::ThemeTokens;
use crate::widgets::code::{render_syntax_line, CodeTheme};
use crate::widgets::Widget;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputTheme {
pub bg: Option<Color>,
pub text: Color,
pub dim: Color,
pub heading: Color,
pub accent: Color,
pub quote: Color,
pub code: CodeTheme,
}
impl OutputTheme {
pub const SCRIN: Self = Self {
bg: None,
text: Color::rgb(201, 209, 217),
dim: Color::rgb(110, 118, 129),
heading: Color::rgb(121, 192, 255),
accent: Color::rgb(63, 185, 80),
quote: Color::rgb(255, 178, 72),
code: CodeTheme::SCRIN,
};
pub fn from_tokens(tokens: ThemeTokens) -> Self {
Self {
bg: Some(tokens.panel),
text: tokens.text,
dim: tokens.dim,
heading: tokens.accent,
accent: tokens.success,
quote: tokens.warning,
code: CodeTheme::from_tokens(tokens),
}
}
}
impl Default for OutputTheme {
fn default() -> Self {
Self::SCRIN
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutputRow {
Blank,
Paragraph(String),
Heading { level: usize, text: String },
Bullet(String),
Quote(String),
CodeFenceStart(String),
Code { text: String, line: usize },
CodeFenceEnd,
}
#[derive(Debug, Clone)]
pub struct MarkdownOutput {
pub content: String,
pub theme: OutputTheme,
pub scroll: ScrollState,
pub wrap: bool,
pub code_line_numbers: bool,
}
impl MarkdownOutput {
pub fn new(content: &str) -> Self {
Self {
content: content.to_string(),
theme: OutputTheme::default(),
scroll: ScrollState::new(),
wrap: true,
code_line_numbers: false,
}
}
pub fn with_theme(mut self, theme: OutputTheme) -> Self {
self.theme = theme;
self
}
pub fn with_scroll(mut self, scroll: ScrollState) -> Self {
self.scroll = scroll;
self
}
pub fn with_wrap(mut self, wrap: bool) -> Self {
self.wrap = wrap;
self
}
pub fn with_code_line_numbers(mut self, show: bool) -> Self {
self.code_line_numbers = show;
self
}
pub fn retained(self) -> RetainedMarkdownOutput {
RetainedMarkdownOutput::from_output(self)
}
pub fn rows_for_width(&self, width: usize) -> Vec<OutputRow> {
parse_rows(&self.content, width.max(1), self.wrap)
}
}
impl Widget for MarkdownOutput {
fn render(&self, buffer: &mut Buffer, area: Rect) {
let rows = self.rows_for_width(area.width as usize);
render_rows(
buffer,
area,
&rows,
self.theme,
self.scroll,
self.code_line_numbers,
);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkdownOutputCache {
width: usize,
wrap: bool,
content: String,
rows: Vec<OutputRow>,
}
#[derive(Debug, Clone)]
pub struct RetainedMarkdownOutput {
pub output: MarkdownOutput,
cache: RefCell<Option<MarkdownOutputCache>>,
}
impl RetainedMarkdownOutput {
pub fn new(content: &str) -> Self {
Self::from_output(MarkdownOutput::new(content))
}
pub fn from_output(output: MarkdownOutput) -> Self {
Self {
output,
cache: RefCell::new(None),
}
}
pub fn with_theme(mut self, theme: OutputTheme) -> Self {
self.output.theme = theme;
self
}
pub fn with_scroll(mut self, scroll: ScrollState) -> Self {
self.output.scroll = scroll;
self
}
pub fn with_wrap(mut self, wrap: bool) -> Self {
self.output.wrap = wrap;
self.invalidate();
self
}
pub fn with_code_line_numbers(mut self, show: bool) -> Self {
self.output.code_line_numbers = show;
self
}
pub fn set_content(&mut self, content: &str) {
self.output.content = content.to_string();
self.invalidate();
}
pub fn invalidate(&self) {
*self.cache.borrow_mut() = None;
}
pub fn rows_for_width(&self, width: usize) -> Vec<OutputRow> {
self.with_rows_for_width(width, |rows| rows.to_vec())
}
pub fn with_rows_for_width<R>(&self, width: usize, f: impl FnOnce(&[OutputRow]) -> R) -> R {
let width = width.max(1);
let needs_rebuild = match self.cache.borrow().as_ref() {
Some(cached) => {
cached.width != width
|| cached.wrap != self.output.wrap
|| cached.content != self.output.content
}
None => true,
};
if needs_rebuild {
let rows = parse_rows(&self.output.content, width, self.output.wrap);
*self.cache.borrow_mut() = Some(MarkdownOutputCache {
width,
wrap: self.output.wrap,
content: self.output.content.clone(),
rows,
});
}
let cache = self.cache.borrow();
let rows = &cache
.as_ref()
.expect("retained markdown cache must exist")
.rows;
f(rows)
}
}
impl Widget for RetainedMarkdownOutput {
fn render(&self, buffer: &mut Buffer, area: Rect) {
self.with_rows_for_width(area.width as usize, |rows| {
render_rows(
buffer,
area,
rows,
self.output.theme,
self.output.scroll,
self.output.code_line_numbers,
);
});
}
}
fn render_rows(
buffer: &mut Buffer,
area: Rect,
rows: &[OutputRow],
theme: OutputTheme,
scroll: ScrollState,
code_line_numbers: bool,
) {
if area.is_empty() {
return;
}
if let Some(bg) = theme.bg {
buffer.fill(area, ' ', theme.text, Some(bg));
}
let mut scroll = scroll;
scroll.set_bounds(rows.len(), area.height as usize);
for (screen_row, row_idx) in scroll.visible_range().enumerate() {
let y = area.y as usize + screen_row;
if y >= area.bottom() as usize {
break;
}
render_row(buffer, area, y, &rows[row_idx], theme, code_line_numbers);
}
}
fn parse_rows(content: &str, width: usize, wrap: bool) -> Vec<OutputRow> {
let mut rows = Vec::new();
let mut in_code = false;
let mut code_line = 1usize;
for raw in content.lines() {
let trimmed = raw.trim_end();
if let Some(lang) = trimmed.strip_prefix("```") {
if in_code {
rows.push(OutputRow::CodeFenceEnd);
in_code = false;
} else {
rows.push(OutputRow::CodeFenceStart(lang.trim().to_string()));
in_code = true;
code_line = 1;
}
continue;
}
if in_code {
rows.push(OutputRow::Code {
text: trimmed.to_string(),
line: code_line,
});
code_line += 1;
continue;
}
if trimmed.is_empty() {
rows.push(OutputRow::Blank);
} else if let Some(text) = trimmed.strip_prefix("### ") {
rows.push(OutputRow::Heading {
level: 3,
text: text.to_string(),
});
} else if let Some(text) = trimmed.strip_prefix("## ") {
rows.push(OutputRow::Heading {
level: 2,
text: text.to_string(),
});
} else if let Some(text) = trimmed.strip_prefix("# ") {
rows.push(OutputRow::Heading {
level: 1,
text: text.to_string(),
});
} else if let Some(text) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
{
push_wrapped(&mut rows, text, width.saturating_sub(2), wrap, |s| {
OutputRow::Bullet(s)
});
} else if let Some(text) = trimmed.strip_prefix("> ") {
push_wrapped(&mut rows, text, width.saturating_sub(2), wrap, |s| {
OutputRow::Quote(s)
});
} else {
push_wrapped(&mut rows, trimmed, width, wrap, OutputRow::Paragraph);
}
}
if rows.is_empty() {
rows.push(OutputRow::Blank);
}
rows
}
fn push_wrapped<F>(rows: &mut Vec<OutputRow>, text: &str, width: usize, wrap: bool, make: F)
where
F: Fn(String) -> OutputRow,
{
if !wrap || width == 0 {
rows.push(make(text.to_string()));
return;
}
let mut line = String::new();
for word in text.split_whitespace() {
let add = if line.is_empty() {
word.len()
} else {
word.len() + 1
};
if !line.is_empty() && line.len() + add > width {
rows.push(make(line));
line = String::new();
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() {
rows.push(make(line));
}
}
fn render_row(
buffer: &mut Buffer,
area: Rect,
y: usize,
row: &OutputRow,
theme: OutputTheme,
code_line_numbers: bool,
) {
match row {
OutputRow::Blank => {}
OutputRow::Paragraph(text) => {
write_line(
buffer,
area.x as usize,
y,
text,
theme.text,
theme.bg,
area.width,
);
}
OutputRow::Heading { level, text } => {
let prefix = match level {
1 => "# ",
2 => "## ",
_ => "### ",
};
let line = format!("{}{}", prefix, text);
buffer.set_str_bold(
area.x as usize,
y,
&sanitize::truncate_str(&line, area.width as usize),
theme.heading,
theme.bg,
);
}
OutputRow::Bullet(text) => {
buffer.set_str(area.x as usize, y, "•", theme.accent, theme.bg);
write_line(
buffer,
area.x as usize + 2,
y,
text,
theme.text,
theme.bg,
area.width.saturating_sub(2),
);
}
OutputRow::Quote(text) => {
buffer.set_str(area.x as usize, y, "▌", theme.quote, theme.bg);
write_line(
buffer,
area.x as usize + 2,
y,
text,
theme.dim,
theme.bg,
area.width.saturating_sub(2),
);
}
OutputRow::CodeFenceStart(lang) => {
fill_code_row(buffer, area, y, theme.code.bg);
let label = if lang.is_empty() {
" code "
} else {
lang.as_str()
};
write_line(
buffer,
area.x as usize + 1,
y,
label,
theme.code.gutter_fg,
Some(theme.code.bg),
area.width.saturating_sub(2),
);
}
OutputRow::Code { text, line } => {
fill_code_row(buffer, area, y, theme.code.bg);
let mut x = area.x as usize + 1;
if code_line_numbers && area.width > 5 {
let gutter = format!("{:>3} │", line);
buffer.set_str(
x,
y,
&gutter,
theme.code.gutter_fg,
Some(theme.code.gutter_bg),
);
x += 6;
}
if x < area.right() as usize {
render_syntax_line(
buffer,
x,
y,
text,
area.right() as usize - x,
theme.code,
Some(theme.code.bg),
);
}
}
OutputRow::CodeFenceEnd => {
fill_code_row(buffer, area, y, theme.code.bg);
}
}
}
fn fill_code_row(buffer: &mut Buffer, area: Rect, y: usize, bg: Color) {
for x in area.x as usize..area.right() as usize {
buffer.set(x, y, Cell::new(' ', Color::WHITE, Some(bg)));
}
}
fn write_line(
buffer: &mut Buffer,
x: usize,
y: usize,
text: &str,
fg: Color,
bg: Option<Color>,
width: u16,
) {
if width == 0 {
return;
}
let text = sanitize::truncate_str(text, width as usize);
buffer.set_str(x, y, &text, fg, bg);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markdown_parses_code_fences() {
let md = MarkdownOutput::new("# Title\n```rust\nfn main() {}\n```");
let rows = md.rows_for_width(40);
assert!(matches!(rows[0], OutputRow::Heading { .. }));
assert!(matches!(rows[1], OutputRow::CodeFenceStart(_)));
assert!(matches!(rows[2], OutputRow::Code { .. }));
}
#[test]
fn markdown_renders_code_backdrop() {
let md = MarkdownOutput::new("```rust\nfn main() {}\n```");
let mut buffer = Buffer::new(40, 5);
md.render(&mut buffer, Rect::new(0, 0, 40, 5));
assert_eq!(buffer.get(0, 0).unwrap().bg, Some(CodeTheme::SCRIN.bg));
assert_eq!(buffer.get(0, 1).unwrap().bg, Some(CodeTheme::SCRIN.bg));
}
#[test]
fn markdown_tiny_area_no_panic() {
let md = MarkdownOutput::new("- tiny\n```\nx\n```");
let mut buffer = Buffer::new(3, 2);
md.render(&mut buffer, Rect::new(0, 0, 3, 2));
}
#[test]
fn retained_markdown_reuses_rows_and_invalidates_on_content() {
let mut md = RetainedMarkdownOutput::new("# One");
assert!(matches!(
md.rows_for_width(20)[0],
OutputRow::Heading { .. }
));
md.set_content("- Two");
assert!(matches!(md.rows_for_width(20)[0], OutputRow::Bullet(_)));
}
}