use crate::app::App;
use crate::markdown::{DocBlock, TableBlockId, update_mermaid_heights};
use crate::theme::Palette;
const LAZY_RENDER_LOOKAHEAD: u32 = 50;
use crate::ui::table_render::layout_table;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug)]
pub struct TableLayout {
pub text: Text<'static>,
}
#[derive(Debug, Default)]
pub struct MarkdownViewState {
pub content: String,
pub rendered: Vec<DocBlock>,
pub scroll_offset: u32,
pub file_name: String,
pub current_path: Option<PathBuf>,
pub total_lines: u32,
pub layout_width: u16,
pub table_layouts: HashMap<TableBlockId, TableLayout>,
}
impl MarkdownViewState {
pub fn load(&mut self, path: PathBuf, file_name: String, content: String, palette: &Palette) {
let blocks = crate::markdown::renderer::render_markdown(&content, palette);
self.total_lines = blocks.iter().map(|b| b.height()).sum();
self.rendered = blocks;
self.content = content;
self.file_name = file_name;
self.current_path = Some(path);
self.scroll_offset = 0;
self.layout_width = 0;
self.table_layouts.clear();
}
pub fn scroll_up(&mut self, n: u16, _view_height: u32) {
self.scroll_offset = self.scroll_offset.saturating_sub(n as u32);
}
pub fn scroll_down(&mut self, n: u16, view_height: u32) {
let max = self.total_lines.saturating_sub(view_height / 2);
self.scroll_offset = (self.scroll_offset + n as u32).min(max);
}
pub fn scroll_half_page_up(&mut self, view_height: u32) {
self.scroll_up((view_height / 2) as u16, view_height);
}
pub fn scroll_half_page_down(&mut self, view_height: u32) {
self.scroll_down((view_height / 2) as u16, view_height);
}
pub fn scroll_page_up(&mut self, view_height: u32) {
self.scroll_up(view_height as u16, view_height);
}
pub fn scroll_page_down(&mut self, view_height: u32) {
self.scroll_down(view_height as u16, view_height);
}
pub fn scroll_to_top(&mut self) {
self.scroll_offset = 0;
}
pub fn scroll_to_bottom(&mut self, view_height: u32) {
self.scroll_offset = self.total_lines.saturating_sub(view_height / 2);
}
}
pub fn draw(f: &mut Frame, app: &mut App, area: Rect, focused: bool) {
let p = app.palette;
let border_style = if focused {
p.border_focused_style()
} else {
p.border_style()
};
let active_tab = app.tabs.active_tab();
let file_name = active_tab.map(|t| t.view.file_name.as_str()).unwrap_or("");
let title: Cow<str> = if file_name.is_empty() {
Cow::Borrowed(" Preview ")
} else {
Cow::Owned(format!(" {file_name} "))
};
let block = Block::default()
.title(title.as_ref())
.title_style(p.title_style())
.borders(Borders::ALL)
.border_style(border_style);
app.tabs.view_height = area.height.saturating_sub(2) as u32;
let has_content = app
.tabs
.active_tab()
.map(|t| !t.view.content.is_empty())
.unwrap_or(false);
if !has_content {
let empty = Paragraph::new("No file selected. Select a markdown file from the tree.")
.style(p.dim_style().bg(p.background))
.block(block);
f.render_widget(empty, area);
return;
}
let view_height = app.tabs.view_height;
let inner = block.inner(area);
f.render_widget(block, area);
let effective_width = if app.show_line_numbers {
let estimate = app
.tabs
.active_tab()
.map(|t| t.view.total_lines.max(10))
.unwrap_or(10);
let num_digits = (estimate.ilog10() + 1).max(4) as u16;
let gutter_width = num_digits + 3;
inner.width.saturating_sub(gutter_width)
} else {
inner.width
};
{
let tab = app.tabs.active_tab_mut().unwrap();
if tab.view.layout_width != effective_width {
tab.view.layout_width = effective_width;
tab.view.table_layouts.clear();
for doc_block in &mut tab.view.rendered {
if let DocBlock::Table(table) = doc_block {
let (text, height, _was_truncated) = layout_table(table, effective_width, &p);
table.rendered_height = height;
tab.view
.table_layouts
.insert(table.id, TableLayout { text });
}
}
update_mermaid_heights(&tab.view.rendered, &app.mermaid_cache);
tab.view.total_lines = tab.view.rendered.iter().map(|b| b.height()).sum();
let max_scroll = tab.view.total_lines.saturating_sub(view_height / 2);
tab.view.scroll_offset = tab.view.scroll_offset.min(max_scroll);
} else {
for doc_block in &mut tab.view.rendered {
if let DocBlock::Table(table) = doc_block
&& let std::collections::hash_map::Entry::Vacant(e) =
tab.view.table_layouts.entry(table.id)
{
let (text, height, _was_truncated) = layout_table(table, effective_width, &p);
table.rendered_height = height;
e.insert(TableLayout { text });
}
}
update_mermaid_heights(&tab.view.rendered, &app.mermaid_cache);
tab.view.total_lines = tab.view.rendered.iter().map(|b| b.height()).sum();
let max_scroll = tab.view.total_lines.saturating_sub(view_height / 2);
tab.view.scroll_offset = tab.view.scroll_offset.min(max_scroll);
}
}
let tab = app.tabs.active_tab().unwrap();
let scroll_offset = tab.view.scroll_offset;
let doc_search_query =
if !tab.doc_search.query.is_empty() && !tab.doc_search.match_lines.is_empty() {
Some((
tab.doc_search.query.clone(),
tab.doc_search
.match_lines
.get(tab.doc_search.current_match)
.copied(),
))
} else {
None
};
let viewport_end = scroll_offset + view_height;
let lookahead_start = scroll_offset.saturating_sub(LAZY_RENDER_LOOKAHEAD);
let lookahead_end = viewport_end + LAZY_RENDER_LOOKAHEAD;
struct TextDraw {
y: u16,
height: u16,
text: Text<'static>,
first_line_number: u32,
}
struct MermaidDraw {
y: u16,
height: u16,
fully_visible: bool,
id: crate::markdown::MermaidBlockId,
source: String,
}
let mut text_draws: Vec<TextDraw> = Vec::new();
let mut mermaid_draws: Vec<MermaidDraw> = Vec::new();
let mut mermaid_to_queue: Vec<(crate::markdown::MermaidBlockId, String)> = Vec::new();
{
let tab = app.tabs.active_tab().unwrap();
let mut block_start = 0u32;
for doc_block in &tab.view.rendered {
let block_height = doc_block.height();
let block_end = block_start + block_height;
if let DocBlock::Mermaid { id, source, .. } = doc_block
&& block_end > lookahead_start
&& block_start < lookahead_end
{
mermaid_to_queue.push((*id, source.clone()));
}
if block_end > scroll_offset && block_start < viewport_end {
let clip_start = scroll_offset.saturating_sub(block_start);
let clip_end = (viewport_end - block_start).min(block_height);
let visible_lines = clip_end.saturating_sub(clip_start);
let y_in_viewport = block_start.saturating_sub(scroll_offset);
let rect_y = inner.y.saturating_add(y_in_viewport as u16);
if rect_y < inner.y + inner.height && visible_lines > 0 {
let draw_height =
visible_lines.min((inner.y + inner.height - rect_y) as u32) as u16;
match doc_block {
DocBlock::Text(text) => {
let start = clip_start as usize;
let end =
(clip_start + visible_lines).min(text.lines.len() as u32) as usize;
let visible_text = if let Some((query, current_line)) =
&doc_search_query
{
let full_text =
highlight_matches(text, query, *current_line, block_start, &p);
let sliced_lines = full_text.lines[start..end].to_vec();
Text::from(sliced_lines)
} else {
let sliced_lines = text.lines[start..end].to_vec();
Text::from(sliced_lines)
};
text_draws.push(TextDraw {
y: rect_y,
height: draw_height,
text: visible_text,
first_line_number: block_start + clip_start + 1,
});
}
DocBlock::Mermaid { id, source, .. } => {
let fully_visible = clip_start == 0
&& visible_lines == block_height
&& draw_height as u32 == block_height;
mermaid_draws.push(MermaidDraw {
y: rect_y,
height: draw_height,
fully_visible,
id: *id,
source: source.clone(),
});
}
DocBlock::Table(table) => {
if let Some(cached) = tab.view.table_layouts.get(&table.id) {
let start = clip_start as usize;
let end = (clip_start + visible_lines)
.min(cached.text.lines.len() as u32)
as usize;
let visible_text =
if let Some((query, current_line)) = &doc_search_query {
let full = highlight_matches(
&cached.text,
query,
*current_line,
block_start,
&p,
);
Text::from(full.lines[start..end].to_vec())
} else {
Text::from(cached.text.lines[start..end].to_vec())
};
text_draws.push(TextDraw {
y: rect_y,
height: draw_height,
text: visible_text,
first_line_number: block_start + clip_start + 1,
});
}
}
}
}
}
block_start = block_end;
if block_start >= lookahead_end {
break;
}
}
}
if let Some(tx) = &app.action_tx {
let in_tmux = std::env::var("TMUX").is_ok();
let tx = tx.clone();
for (id, source) in mermaid_to_queue {
app.mermaid_cache
.ensure_queued(id, &source, app.picker.as_ref(), &tx, in_tmux);
}
}
let total_doc_lines = app
.tabs
.active_tab()
.map(|t| t.view.total_lines)
.unwrap_or(0);
for td in text_draws {
let rect = Rect {
x: inner.x,
y: td.y,
width: inner.width,
height: td.height,
};
if app.show_line_numbers {
render_text_with_gutter(f, rect, td.text, td.first_line_number, total_doc_lines, &p);
} else {
let para = Paragraph::new(td.text).wrap(Wrap { trim: false });
f.render_widget(para, rect);
}
}
for md in mermaid_draws {
let rect = Rect {
x: inner.x,
y: md.y,
width: inner.width,
height: md.height,
};
draw_mermaid_block(f, app, rect, md.fully_visible, md.id, &md.source, &p);
}
}
fn draw_mermaid_block(
f: &mut Frame,
app: &mut App,
rect: Rect,
fully_visible: bool,
id: crate::markdown::MermaidBlockId,
source: &str,
p: &Palette,
) {
use crate::mermaid::MermaidEntry;
let entry = app.mermaid_cache.get_mut(&id);
match entry {
None => {
render_mermaid_placeholder(f, rect, "mermaid diagram", p);
}
Some(MermaidEntry::Pending) => {
render_mermaid_placeholder(f, rect, "rendering\u{2026}", p);
}
Some(MermaidEntry::Ready { protocol, .. }) => {
if fully_visible {
use ratatui::widgets::Clear;
use ratatui_image::{Resize, StatefulImage};
f.render_widget(Clear, rect);
let padded = padded_rect(rect, 4, 1);
let image = StatefulImage::new().resize(Resize::Fit(None));
f.render_stateful_widget(image, padded, protocol.as_mut());
} else {
render_mermaid_placeholder(f, rect, "scroll to view diagram", p);
}
}
Some(MermaidEntry::Failed(msg)) => {
let footer = format!("[mermaid \u{2014} {}]", truncate(msg, 60));
render_mermaid_source(f, rect, source, &footer, p);
}
Some(MermaidEntry::SourceOnly(reason)) => {
let footer = format!("[mermaid \u{2014} {}]", reason.clone());
render_mermaid_source(f, rect, source, &footer, p);
}
}
}
fn padded_rect(rect: Rect, h: u16, v: u16) -> Rect {
if rect.width <= h * 2 || rect.height <= v * 2 {
return rect;
}
Rect {
x: rect.x + h,
y: rect.y + v,
width: rect.width - h * 2,
height: rect.height - v * 2,
}
}
fn render_mermaid_placeholder(f: &mut Frame, rect: Rect, msg: &str, p: &Palette) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(p.border_style());
let inner = block.inner(rect);
f.render_widget(block, rect);
if inner.height > 0 {
let line = Line::from(Span::styled(msg.to_string(), p.dim_style()));
let para =
Paragraph::new(Text::from(vec![line])).alignment(ratatui::layout::Alignment::Center);
let y_offset = inner.height / 2;
let target = Rect {
y: inner.y + y_offset,
height: 1,
..inner
};
f.render_widget(para, target);
}
}
fn render_mermaid_source(f: &mut Frame, rect: Rect, source: &str, footer: &str, p: &Palette) {
let code_style = Style::default().fg(p.code_fg).bg(p.code_bg);
let dim_style = p.dim_style();
let mut lines: Vec<Line<'static>> = source
.lines()
.map(|l| Line::from(Span::styled(l.to_string(), code_style)))
.collect();
lines.push(Line::from(Span::styled(footer.to_string(), dim_style)));
let block = Block::default()
.borders(Borders::ALL)
.border_style(p.border_style());
let para = Paragraph::new(Text::from(lines))
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(para, rect);
}
fn render_text_with_gutter(
f: &mut Frame,
rect: Rect,
text: Text<'static>,
first_line_number: u32,
total_doc_lines: u32,
p: &Palette,
) {
let slice_len = text.lines.len() as u32;
let num_digits = if total_doc_lines == 0 {
4
} else {
(total_doc_lines.ilog10() + 1).max(4)
};
let gutter_width = num_digits + 3;
let chunks = Layout::horizontal([Constraint::Length(gutter_width as u16), Constraint::Min(0)])
.split(rect);
let gutter_style = Style::new().fg(p.gutter);
let gutter_lines: Vec<Line<'static>> = (first_line_number..first_line_number + slice_len)
.map(|n| {
Line::from(Span::styled(
format!("{:>width$} | ", n, width = num_digits as usize),
gutter_style,
))
})
.collect();
f.render_widget(Paragraph::new(Text::from(gutter_lines)), chunks[0]);
f.render_widget(Paragraph::new(text).wrap(Wrap { trim: false }), chunks[1]);
}
fn highlight_matches(
text: &Text<'static>,
query: &str,
current_line: Option<u32>,
block_start: u32,
p: &Palette,
) -> Text<'static> {
let query_lower = query.to_lowercase();
let match_style = Style::default()
.bg(p.search_match_bg)
.fg(p.match_fg)
.add_modifier(Modifier::BOLD);
let current_style = Style::default()
.bg(p.current_match_bg)
.fg(p.match_fg)
.add_modifier(Modifier::BOLD);
let lines: Vec<Line<'static>> = text
.lines
.iter()
.enumerate()
.map(|(line_idx, line)| {
let line_text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
if !line_text.to_lowercase().contains(&query_lower) {
return line.clone();
}
let is_current = current_line == Some(block_start + line_idx as u32);
let hl_style = if is_current {
current_style
} else {
match_style
};
let mut new_spans: Vec<Span<'static>> = Vec::new();
for span in &line.spans {
split_and_highlight(
&span.content,
&query_lower,
span.style,
hl_style,
&mut new_spans,
);
}
Line::from(new_spans)
})
.collect();
Text::from(lines)
}
fn split_and_highlight(
text: &str,
query_lower: &str,
base_style: Style,
highlight_style: Style,
out: &mut Vec<Span<'static>>,
) {
let text_lower = text.to_lowercase();
let mut start = 0;
while let Some(pos) = text_lower[start..].find(query_lower) {
let abs_pos = start + pos;
if abs_pos > start {
out.push(Span::styled(text[start..abs_pos].to_string(), base_style));
}
let match_end = abs_pos + query_lower.len();
out.push(Span::styled(
text[abs_pos..match_end].to_string(),
highlight_style,
));
start = match_end;
}
if start < text.len() {
out.push(Span::styled(text[start..].to_string(), base_style));
}
}
fn truncate(s: &str, max: usize) -> &str {
if s.len() <= max { s } else { &s[..max] }
}