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::{Color, 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, Clone, Copy, PartialEq, Eq)]
pub struct VisualRange {
pub anchor: u32,
pub cursor: u32,
}
impl VisualRange {
pub fn top(&self) -> u32 {
self.anchor.min(self.cursor)
}
pub fn bottom(&self) -> u32 {
self.anchor.max(self.cursor)
}
pub fn contains(&self, line: u32) -> bool {
line >= self.top() && line <= self.bottom()
}
}
#[derive(Debug, Clone)]
pub struct AbsoluteLink {
pub line: u32,
pub col_start: u16,
pub col_end: u16,
pub url: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct AbsoluteAnchor {
pub anchor: String,
pub line: u32,
}
#[derive(Debug, Default)]
pub struct MarkdownViewState {
pub content: String,
pub rendered: Vec<DocBlock>,
pub scroll_offset: u32,
pub cursor_line: u32,
pub file_name: String,
pub current_path: Option<PathBuf>,
pub total_lines: u32,
pub layout_width: u16,
pub table_layouts: HashMap<TableBlockId, TableLayout>,
pub links: Vec<AbsoluteLink>,
pub heading_anchors: Vec<AbsoluteAnchor>,
pub visual_mode: Option<VisualRange>,
}
impl MarkdownViewState {
pub fn recompute_positions(&mut self) {
let mut abs_links: Vec<AbsoluteLink> = Vec::new();
let mut abs_anchors: Vec<AbsoluteAnchor> = Vec::new();
let mut block_offset = 0u32;
for block in &self.rendered {
if let DocBlock::Text {
links,
heading_anchors,
..
} = block
{
for link in links {
abs_links.push(AbsoluteLink {
line: block_offset + link.line,
col_start: link.col_start,
col_end: link.col_end,
url: link.url.clone(),
text: link.text.clone(),
});
}
for ha in heading_anchors {
abs_anchors.push(AbsoluteAnchor {
anchor: ha.anchor.clone(),
line: block_offset + ha.line,
});
}
}
block_offset += block.height();
}
self.links = abs_links;
self.heading_anchors = abs_anchors;
}
pub fn load(
&mut self,
path: PathBuf,
file_name: String,
content: String,
palette: &Palette,
theme: crate::theme::Theme,
) {
let blocks = crate::markdown::renderer::render_markdown(&content, palette, theme);
self.total_lines = blocks.iter().map(|b| b.height()).sum();
self.rendered = blocks;
self.recompute_positions();
self.content = content;
self.file_name = file_name;
self.current_path = Some(path);
self.scroll_offset = 0;
self.cursor_line = 0;
self.visual_mode = None;
self.layout_width = 0;
self.table_layouts.clear();
}
pub fn cursor_down(&mut self, n: u32) {
let max = self.total_lines.saturating_sub(1);
self.cursor_line = self.cursor_line.saturating_add(n).min(max);
if let Some(range) = self.visual_mode.as_mut() {
range.cursor = self.cursor_line;
}
}
pub fn cursor_up(&mut self, n: u32) {
self.cursor_line = self.cursor_line.saturating_sub(n);
if let Some(range) = self.visual_mode.as_mut() {
range.cursor = self.cursor_line;
}
}
pub fn cursor_to_top(&mut self) {
self.cursor_line = 0;
self.scroll_offset = 0;
if let Some(range) = self.visual_mode.as_mut() {
range.cursor = 0;
}
}
pub fn cursor_to_bottom(&mut self, view_height: u32) {
self.cursor_line = self.total_lines.saturating_sub(1);
self.scroll_to_cursor(view_height);
if let Some(range) = self.visual_mode.as_mut() {
range.cursor = self.cursor_line;
}
}
pub fn scroll_to_cursor_centered(&mut self, view_height: u32) {
let vh = view_height.max(1);
let half = vh / 2;
self.scroll_offset = self.cursor_line.saturating_sub(half);
let max = self.total_lines.saturating_sub(vh / 2);
self.scroll_offset = self.scroll_offset.min(max);
}
pub fn scroll_to_cursor(&mut self, view_height: u32) {
let vh = view_height.max(1);
if self.cursor_line < self.scroll_offset {
self.scroll_offset = self.cursor_line;
} else if self.cursor_line >= self.scroll_offset + vh {
self.scroll_offset = self.cursor_line.saturating_sub(vh - 1);
}
let max = self.total_lines.saturating_sub(vh / 2);
self.scroll_offset = self.scroll_offset.min(max);
}
}
pub fn draw(f: &mut Frame, app: &mut App, area: Rect, focused: bool) {
let p = app.palette;
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 = if app.tree_hidden {
Block::default().style(Style::default().bg(p.background))
} else {
let border_style = if focused {
p.border_focused_style()
} else {
p.border_style()
};
Block::default()
.title(title.as_ref())
.title_style(p.title_style())
.borders(Borders::ALL)
.border_style(border_style)
.style(Style::default().bg(p.background))
};
app.tabs.view_height = block.inner(area).height 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();
tab.view.recompute_positions();
let max_scroll = tab.view.total_lines.saturating_sub(view_height / 2);
tab.view.scroll_offset = tab.view.scroll_offset.min(max_scroll);
} else {
let mut layout_changed = false;
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 });
layout_changed = true;
}
}
let mermaid_changed = update_mermaid_heights(&tab.view.rendered, &app.mermaid_cache);
if layout_changed || mermaid_changed {
tab.view.total_lines = tab.view.rendered.iter().map(|b| b.height()).sum();
tab.view.recompute_positions();
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 cursor_line = tab.view.cursor_line;
let visual_mode = tab.view.visual_mode;
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,
block_start: u32,
block_height: u32,
visual_mode: Option<VisualRange>,
}
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 mut 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)
};
let block_end = block_start + block_height;
if focused {
apply_block_highlight(
&mut visible_text.lines,
visual_mode,
cursor_line,
block_start,
block_end,
start,
p.selection_bg,
);
}
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 max_renderable = block_height.min(inner.height as u32);
let fully_visible = visible_lines >= max_renderable
&& draw_height as u32 >= max_renderable;
mermaid_draws.push(MermaidDraw {
y: rect_y,
height: draw_height,
fully_visible,
id: *id,
source: source.clone(),
block_start,
block_height,
visual_mode,
});
}
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 mut 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())
};
let block_end = block_start + block_height;
if focused {
apply_block_highlight(
&mut visible_text.lines,
visual_mode,
cursor_line,
block_start,
block_end,
start,
p.selection_bg,
);
}
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();
let bg_rgb = match p.background {
Color::Rgb(r, g, b) => (r, g, b),
_ => (0, 0, 0),
};
for (id, source) in mermaid_to_queue {
app.mermaid_cache
.ensure_queued(id, &source, app.picker.as_ref(), &tx, in_tmux, bg_rgb);
}
}
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,
};
let params = MermaidDrawParams {
fully_visible: md.fully_visible,
id: md.id,
source: &md.source,
focused,
cursor_line,
block_start: md.block_start,
block_end: md.block_start + md.block_height,
visual_mode: md.visual_mode,
};
draw_mermaid_block(f, app, rect, &p, ¶ms);
}
}
struct MermaidDrawParams<'a> {
fully_visible: bool,
id: crate::markdown::MermaidBlockId,
source: &'a str,
focused: bool,
cursor_line: u32,
block_start: u32,
block_end: u32,
visual_mode: Option<VisualRange>,
}
fn draw_mermaid_block(
f: &mut Frame,
app: &mut App,
rect: Rect,
p: &Palette,
params: &MermaidDrawParams,
) {
use crate::mermaid::MermaidEntry;
let entry = app.mermaid_cache.get_mut(¶ms.id);
let cursor_in_block = params.focused
&& params.cursor_line >= params.block_start
&& params.cursor_line < params.block_end;
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 params.fully_visible {
use ratatui_image::{Resize, StatefulImage};
f.render_widget(
Block::default().style(Style::default().bg(p.background)),
rect,
);
let highlighted_rows: Vec<u32> = match params.visual_mode {
Some(range) => {
let top = range.top().max(params.block_start) - params.block_start;
let bottom = range.bottom().min(params.block_end.saturating_sub(1))
- params.block_start;
(top..=bottom).collect()
}
None if cursor_in_block => {
vec![params.cursor_line - params.block_start]
}
None => vec![],
};
for row_offset in highlighted_rows {
let row_offset = row_offset as u16;
if row_offset < rect.height {
let bar_rect = Rect {
x: rect.x,
y: rect.y + row_offset,
width: rect.width,
height: 1,
};
f.render_widget(
Block::default().style(Style::default().bg(p.selection_bg)),
bar_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));
let mut text = render_mermaid_source_text(params.source, &footer, p);
if params.focused {
apply_block_highlight(
&mut text.lines,
params.visual_mode,
params.cursor_line,
params.block_start,
params.block_end,
0,
p.selection_bg,
);
}
render_mermaid_source_styled(f, rect, text, p);
}
Some(MermaidEntry::SourceOnly(reason)) => {
let footer = format!("[mermaid \u{2014} {}]", reason);
let mut text = render_mermaid_source_text(params.source, &footer, p);
if params.focused {
apply_block_highlight(
&mut text.lines,
params.visual_mode,
params.cursor_line,
params.block_start,
params.block_end,
0,
p.selection_bg,
);
}
render_mermaid_source_styled(f, rect, text, 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())
.style(Style::default().bg(p.background));
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_text(source: &str, footer: &str, p: &Palette) -> Text<'static> {
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)));
Text::from(lines)
}
fn render_mermaid_source_styled(f: &mut Frame, rect: Rect, text: Text<'static>, p: &Palette) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(p.border_style())
.style(Style::default().bg(p.background));
let para = Paragraph::new(text).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 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 content_width = chunks[1].width;
let gutter_style = Style::new().fg(p.gutter);
let mut gutter_lines: Vec<Line<'static>> = Vec::with_capacity(text.lines.len());
let blank_span = Span::styled(
format!("{:>width$} | ", "", width = num_digits as usize),
gutter_style,
);
for (i, line) in text.lines.iter().enumerate() {
gutter_lines.push(Line::from(Span::styled(
format!(
"{:>width$} | ",
first_line_number + i as u32,
width = num_digits as usize
),
gutter_style,
)));
let wraps = line_visual_rows(line, content_width);
for _ in 1..wraps {
gutter_lines.push(Line::from(blank_span.clone()));
}
}
f.render_widget(Paragraph::new(Text::from(gutter_lines)), chunks[0]);
f.render_widget(Paragraph::new(text).wrap(Wrap { trim: false }), chunks[1]);
}
fn apply_block_highlight(
lines: &mut [Line<'static>],
visual_mode: Option<VisualRange>,
cursor_line: u32,
block_start: u32,
block_end: u32,
clip_start: usize,
bg: Color,
) {
match visual_mode {
Some(range) => {
let block_visible_start = block_start + clip_start as u32;
let block_visible_end = block_start + clip_start as u32 + lines.len() as u32;
for abs in block_visible_start..block_visible_end {
if range.contains(abs) {
let idx = (abs - block_visible_start) as usize;
patch_cursor_highlight(lines, idx, bg);
}
}
}
None => {
if cursor_line >= block_start && cursor_line < block_end {
let cursor_relative = (cursor_line - block_start) as usize;
if cursor_relative >= clip_start {
let idx = cursor_relative - clip_start;
patch_cursor_highlight(lines, idx, bg);
}
}
}
}
}
fn patch_cursor_highlight(lines: &mut [Line<'static>], idx: usize, bg: Color) {
let Some(line) = lines.get_mut(idx) else {
return;
};
if line.spans.is_empty() {
*line = Line::from(Span::styled(" ".to_string(), Style::default().bg(bg)));
} else {
for span in line.spans.iter_mut() {
span.style = span.style.patch(Style::default().bg(bg));
}
}
}
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] }
}
fn line_visual_rows(line: &Line, content_width: u16) -> u32 {
if content_width == 0 {
return 1;
}
let width: usize = line
.spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if width == 0 {
return 1;
}
let cw = content_width as usize;
width.div_ceil(cw) as u32
}
pub fn visual_row_to_logical_line(
blocks: &[DocBlock],
scroll_offset: u32,
visual_row: u32,
content_width: u16,
) -> u32 {
let mut remaining_visual = visual_row;
let mut block_offset = 0u32;
for block in blocks {
let block_height = block.height();
let block_end = block_offset + block_height;
if block_end <= scroll_offset {
block_offset = block_end;
continue;
}
let clip_start = scroll_offset.saturating_sub(block_offset) as usize;
match block {
DocBlock::Text { text, .. } => {
for (idx, line) in text.lines.iter().enumerate().skip(clip_start) {
let rows = line_visual_rows(line, content_width);
if remaining_visual < rows {
return block_offset + idx as u32;
}
remaining_visual -= rows;
}
}
DocBlock::Mermaid { cell_height, .. } => {
let visible_rows = cell_height.get().saturating_sub(clip_start as u32);
if remaining_visual < visible_rows {
return u32::MAX;
}
remaining_visual -= visible_rows;
}
DocBlock::Table(t) => {
let visible_rows = t.rendered_height.saturating_sub(clip_start as u32);
if remaining_visual < visible_rows {
return u32::MAX;
}
remaining_visual -= visible_rows;
}
}
block_offset = block_end;
}
u32::MAX
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown::{DocBlock, HeadingAnchor, LinkInfo};
use ratatui::text::{Line, Span, Text};
#[test]
fn visual_range_contains_inclusive() {
let r = VisualRange {
anchor: 3,
cursor: 5,
};
assert!(r.contains(3), "should contain anchor");
assert!(r.contains(4), "should contain middle");
assert!(r.contains(5), "should contain cursor");
assert!(!r.contains(2), "should not contain below anchor");
assert!(!r.contains(6), "should not contain above cursor");
}
#[test]
fn visual_range_contains_reversed() {
let r = VisualRange {
anchor: 5,
cursor: 3,
};
assert!(r.contains(3));
assert!(r.contains(4));
assert!(r.contains(5));
assert!(!r.contains(2));
assert!(!r.contains(6));
}
#[test]
fn load_clears_visual_mode() {
use crate::theme::{Palette, Theme};
let palette = Palette::from_theme(Theme::Default);
let mut view = MarkdownViewState {
visual_mode: Some(VisualRange {
anchor: 2,
cursor: 4,
}),
..Default::default()
};
view.load(
std::path::PathBuf::from("/fake/test.md"),
"test.md".to_string(),
"hello\nworld\n".to_string(),
&palette,
Theme::Default,
);
assert_eq!(view.visual_mode, None, "load() must clear visual_mode");
}
#[test]
fn cursor_down_in_visual_mode_extends_range() {
let mut v = MarkdownViewState {
total_lines: 10,
cursor_line: 3,
visual_mode: Some(VisualRange {
anchor: 3,
cursor: 3,
}),
..Default::default()
};
v.cursor_down(2);
let range = v.visual_mode.unwrap();
assert_eq!(range.anchor, 3, "anchor must stay fixed");
assert_eq!(range.cursor, 5, "cursor must extend down");
}
#[test]
fn cursor_up_in_visual_mode_extends_range() {
let mut v = MarkdownViewState {
total_lines: 10,
cursor_line: 5,
visual_mode: Some(VisualRange {
anchor: 5,
cursor: 5,
}),
..Default::default()
};
v.cursor_up(3);
let range = v.visual_mode.unwrap();
assert_eq!(range.anchor, 5, "anchor must stay fixed");
assert_eq!(range.cursor, 2, "cursor must move up");
}
fn view_with_lines(total: u32) -> MarkdownViewState {
MarkdownViewState {
total_lines: total,
..Default::default()
}
}
#[test]
fn cursor_down_then_up_returns_home() {
let mut v = view_with_lines(5);
v.cursor_down(3);
assert_eq!(v.cursor_line, 3);
v.cursor_up(3);
assert_eq!(v.cursor_line, 0);
}
#[test]
fn cursor_down_clamps_to_last_line() {
let mut v = view_with_lines(3);
v.cursor_down(100);
assert_eq!(v.cursor_line, 2);
}
#[test]
fn cursor_scroll_follows_when_off_screen() {
let mut v = view_with_lines(10);
v.scroll_offset = 0;
v.cursor_line = 7;
v.scroll_to_cursor(5);
assert_eq!(v.scroll_offset, 3);
}
#[test]
fn cursor_scroll_unchanged_when_already_visible() {
let mut v = view_with_lines(20);
v.scroll_offset = 5;
v.cursor_line = 7;
v.scroll_to_cursor(10);
assert_eq!(v.scroll_offset, 5);
}
fn make_text_block_with_sources(source_lines: Vec<u32>) -> DocBlock {
let n = source_lines.len();
let text_lines: Vec<Line<'static>> = (0..n)
.map(|i| Line::from(Span::raw(format!("line {i}"))))
.collect();
DocBlock::Text {
text: Text::from(text_lines),
links: Vec::<LinkInfo>::new(),
heading_anchors: Vec::<HeadingAnchor>::new(),
source_lines,
}
}
#[test]
fn source_line_at_text_block_exact() {
use crate::markdown::source_line_at;
let block = make_text_block_with_sources(vec![0, 1, 2]);
let blocks = vec![block];
assert_eq!(source_line_at(&blocks, 0), 0);
assert_eq!(source_line_at(&blocks, 1), 1);
assert_eq!(source_line_at(&blocks, 2), 2);
}
#[test]
fn source_line_at_table_block_returns_table_start() {
use crate::markdown::source_line_at;
use crate::markdown::{TableBlock, TableBlockId};
let block = DocBlock::Table(TableBlock {
id: TableBlockId(0),
headers: vec![],
rows: vec![],
alignments: vec![],
natural_widths: vec![],
rendered_height: 4,
source_line: 5,
row_source_lines: vec![],
});
let blocks = vec![block];
assert_eq!(source_line_at(&blocks, 0), 5);
assert_eq!(source_line_at(&blocks, 3), 5);
}
fn make_lines(count: usize) -> Vec<Line<'static>> {
(0..count)
.map(|i| Line::from(Span::raw(format!("line {i}"))))
.collect()
}
#[test]
fn patch_cursor_highlight_patches_given_line() {
use ratatui::style::Color;
let bg = Color::Rgb(30, 30, 100);
let mut lines = make_lines(3);
patch_cursor_highlight(&mut lines, 1, bg);
for span in &lines[1].spans {
assert_eq!(span.style.bg, Some(bg), "line 1 span must have bg color");
}
for span in &lines[0].spans {
assert_eq!(span.style.bg, None, "line 0 must be untouched");
}
for span in &lines[2].spans {
assert_eq!(span.style.bg, None, "line 2 must be untouched");
}
}
#[test]
fn patch_cursor_highlight_fills_empty_line() {
use ratatui::style::Color;
let bg = Color::Rgb(50, 50, 150);
let mut lines = vec![
Line::from(Span::raw("before")),
Line::from(vec![]), Line::from(Span::raw("after")),
];
patch_cursor_highlight(&mut lines, 1, bg);
assert_eq!(
lines[1].spans.len(),
1,
"empty line must have a filler span injected"
);
assert_eq!(
lines[1].spans[0].content.as_ref(),
" ",
"filler span must be a single space"
);
assert_eq!(lines[1].spans[0].style.bg, Some(bg));
}
#[test]
fn patch_cursor_highlight_out_of_bounds_noop() {
use ratatui::style::Color;
let bg = Color::Rgb(10, 10, 10);
let mut lines = make_lines(2);
patch_cursor_highlight(&mut lines, 2, bg);
for line in &lines {
for span in &line.spans {
assert_eq!(span.style.bg, None);
}
}
}
#[test]
fn source_line_at_table_block_per_row() {
use crate::markdown::{TableBlock, TableBlockId, source_line_at};
let block = DocBlock::Table(TableBlock {
id: TableBlockId(0),
headers: vec![vec![Span::raw("H")]],
rows: vec![vec![vec![Span::raw("a")]], vec![vec![Span::raw("b")]]],
alignments: vec![pulldown_cmark::Alignment::None],
natural_widths: vec![1],
rendered_height: 6,
source_line: 5,
row_source_lines: vec![5, 7, 8],
});
let blocks = vec![block];
assert_eq!(source_line_at(&blocks, 0), 5, "top border -> header");
assert_eq!(source_line_at(&blocks, 1), 5, "header row");
assert_eq!(source_line_at(&blocks, 2), 5, "separator -> header");
assert_eq!(source_line_at(&blocks, 3), 7, "body[0]");
assert_eq!(source_line_at(&blocks, 4), 8, "body[1]");
assert_eq!(source_line_at(&blocks, 5), 8, "bottom border -> last body");
}
#[test]
fn table_row_source_line_helper_boundary_cases() {
use crate::markdown::{TableBlock, TableBlockId, source_line_at};
let header_only = DocBlock::Table(TableBlock {
id: TableBlockId(1),
headers: vec![vec![Span::raw("H")]],
rows: vec![],
alignments: vec![pulldown_cmark::Alignment::None],
natural_widths: vec![1],
rendered_height: 3,
source_line: 10,
row_source_lines: vec![10],
});
let blocks = vec![header_only];
assert_eq!(source_line_at(&blocks, 0), 10);
assert_eq!(source_line_at(&blocks, 1), 10);
assert_eq!(source_line_at(&blocks, 2), 10);
let empty_rsl = DocBlock::Table(TableBlock {
id: TableBlockId(2),
headers: vec![vec![Span::raw("H")]],
rows: vec![vec![vec![Span::raw("a")]]],
alignments: vec![pulldown_cmark::Alignment::None],
natural_widths: vec![1],
rendered_height: 4,
source_line: 99,
row_source_lines: vec![],
});
let blocks2 = vec![empty_rsl];
for i in 0..4 {
assert_eq!(source_line_at(&blocks2, i), 99, "empty rsl row {i}");
}
}
}