use std::cell::RefCell;
use crate::core::buffer::{Buffer, Cell};
use crate::core::color::Color;
use crate::core::rect::Rect;
use crate::interaction::{
CopyTransform, HitRegion, InteractionLayer, ScrollRowHit, SelectableSpan, SelectionGroup,
TextRange, WidgetAction, WidgetId, WidgetRole, WidgetValue,
};
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,
pub selectable: bool,
pub region_id: Option<WidgetId>,
}
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,
selectable: false,
region_id: None,
}
}
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 with_selectable(mut self, selectable: bool) -> Self {
self.selectable = selectable;
self
}
pub fn with_region_id(mut self, id: impl Into<WidgetId>) -> Self {
self.region_id = Some(id.into());
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)
}
pub fn render_with_interaction(
&self,
buffer: &mut Buffer,
area: Rect,
layer: &mut InteractionLayer,
) {
let rows = self.rows_for_width(area.width as usize);
render_rows(
buffer,
area,
&rows,
self.theme,
self.scroll,
self.code_line_numbers,
);
register_markdown_interaction(
layer,
area,
&rows,
self.scroll,
self.code_line_numbers,
self.selectable,
self.region_id
.clone()
.unwrap_or_else(|| WidgetId::new("markdown")),
);
}
}
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 with_selectable(mut self, selectable: bool) -> Self {
self.output.selectable = selectable;
self
}
pub fn with_region_id(mut self, id: impl Into<WidgetId>) -> Self {
self.output.region_id = Some(id.into());
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)
}
pub fn render_with_interaction(
&self,
buffer: &mut Buffer,
area: Rect,
layer: &mut InteractionLayer,
) {
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,
);
register_markdown_interaction(
layer,
area,
rows,
self.output.scroll,
self.output.code_line_numbers,
self.output.selectable,
self.output
.region_id
.clone()
.unwrap_or_else(|| WidgetId::new("markdown")),
);
});
}
}
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 register_markdown_interaction(
layer: &mut InteractionLayer,
area: Rect,
rows: &[OutputRow],
scroll: ScrollState,
code_line_numbers: bool,
selectable: bool,
region_id: WidgetId,
) {
if area.is_empty() {
return;
}
layer.push_region(
HitRegion::new(region_id.clone(), area)
.with_role(WidgetRole::Transcript)
.with_label("markdown transcript")
.with_value(WidgetValue::Count(rows.len())),
);
let block_indexes = code_block_indexes(rows);
let body_group = SelectionGroup::new(format!("{}:body", region_id.as_ref()));
let mut scroll = scroll;
scroll.set_bounds(rows.len(), area.height as usize);
let mut row_hits = Vec::new();
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;
}
let row_id = WidgetId::new(format!("{}:row:{}", region_id.as_ref(), row_idx));
let row_area = Rect::new(area.x, y as u16, area.width, 1);
let role = match rows[row_idx] {
OutputRow::Code { .. } => WidgetRole::CodeLine,
OutputRow::CodeFenceStart(_) | OutputRow::CodeFenceEnd => WidgetRole::CodeBlock,
_ => WidgetRole::TranscriptRow,
};
let label = output_row_plain_text(&rows[row_idx]);
layer.push_region(
HitRegion::new(row_id.clone(), row_area)
.with_role(role)
.with_label(label.clone())
.with_action(WidgetAction::Select)
.with_row(row_idx)
.with_z_index(1),
);
let span_id = format!("{}:span:{}", region_id.as_ref(), row_idx);
row_hits.push(
ScrollRowHit::new(row_id.clone(), row_idx)
.with_source_line(row_idx)
.with_span_id(span_id.clone())
.with_item_id(row_id),
);
if !selectable || label.is_empty() {
continue;
}
if matches!(
rows[row_idx],
OutputRow::CodeFenceStart(_) | OutputRow::CodeFenceEnd
) {
continue;
}
let (text_x, text_width, group, transform) = match rows[row_idx] {
OutputRow::Code { .. } => {
let mut x = area.x as usize + 1;
if code_line_numbers && area.width > 5 {
x += 6;
}
let block = block_indexes[row_idx].unwrap_or(0);
(
x,
(area.right() as usize).saturating_sub(x),
SelectionGroup::new(format!("{}:code:{}", region_id.as_ref(), block)),
CopyTransform::CodeOnly,
)
}
OutputRow::Bullet(_) | OutputRow::Quote(_) => (
area.x as usize + 2,
area.width.saturating_sub(2) as usize,
body_group.clone(),
CopyTransform::PlainText,
),
_ => (
area.x as usize,
area.width as usize,
body_group.clone(),
CopyTransform::PlainText,
),
};
if text_width == 0 || text_x >= area.right() as usize {
continue;
}
let display = sanitize::sanitize_str(&label, text_width);
let width = sanitize::str_display_width(&display).min(text_width);
layer.push_selectable_span(
SelectableSpan::new(
span_id,
display.clone(),
0..display.len(),
Rect::new(text_x as u16, y as u16, width as u16, 1),
)
.with_source_id(region_id.clone())
.with_group(group)
.with_logical_range(TextRange::new(
row_idx,
0,
sanitize::str_display_width(&display),
))
.with_copy_transform(transform),
);
}
layer.push_scroll_region(region_id, area, scroll.offset, row_hits);
}
fn code_block_indexes(rows: &[OutputRow]) -> Vec<Option<usize>> {
let mut out = Vec::with_capacity(rows.len());
let mut current = None;
let mut next = 0usize;
for row in rows {
match row {
OutputRow::CodeFenceStart(_) => {
current = Some(next);
out.push(current);
next += 1;
}
OutputRow::Code { .. } => out.push(current),
OutputRow::CodeFenceEnd => {
out.push(current);
current = None;
}
_ => out.push(None),
}
}
out
}
fn output_row_plain_text(row: &OutputRow) -> String {
match row {
OutputRow::Blank => String::new(),
OutputRow::Paragraph(text)
| OutputRow::Bullet(text)
| OutputRow::Quote(text)
| OutputRow::Code { text, .. } => text.clone(),
OutputRow::Heading { level, text } => format!("{} {}", "#".repeat(*level), text),
OutputRow::CodeFenceStart(lang) => {
if lang.is_empty() {
"code".to_string()
} else {
format!("code: {}", lang)
}
}
OutputRow::CodeFenceEnd => String::new(),
}
}
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(_)));
}
#[test]
fn markdown_interaction_copies_code_without_fences() {
let md = RetainedMarkdownOutput::new("```rust\nfn main() {}\n```\ntext")
.with_selectable(true)
.with_region_id("msg:1");
let mut buffer = Buffer::new(32, 5);
let mut layer = InteractionLayer::new();
md.render_with_interaction(&mut buffer, Rect::new(0, 0, 32, 5), &mut layer);
assert_eq!(
layer.plain_text_for_group(&SelectionGroup::new("msg:1:code:0")),
"fn main() {}"
);
assert_eq!(
layer.scroll_hit_test(1, 1).unwrap().region_id.as_ref(),
"msg:1"
);
}
}