use ratatui::{buffer::Buffer, layout::Rect, style::Style, text::Line, widgets::StatefulWidget};
use super::line_render::{render_line_from_visual, visual_rows_of_chars};
use crate::document::VisualSelection;
#[derive(Debug, Default)]
pub struct PreviewState {
pub scroll: usize,
pub selection: Option<VisualSelection>,
pub selection_style: Style,
pub image_snapshots: Vec<super::ImageLayoutSnapshot>,
pub image_snapshots_key: Option<(usize, ratatui::layout::Rect, u64)>,
pub link_snapshots: Vec<super::LinkLayoutSnapshot>,
pub link_snapshots_key: Option<(usize, ratatui::layout::Rect, u64)>,
}
pub struct PreviewView<'a> {
pub lines: &'a [Line<'static>],
pub scroll: usize,
}
impl<'a> StatefulWidget for PreviewView<'a> {
type State = PreviewState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
if area.height == 0 {
return;
}
let sel_range = state.selection.map(|s| s.range());
let band_cols = state.selection.and_then(|s| s.band).map(|b| b.cols);
let sel_style = state.selection_style;
let width = area.width as usize;
let (mut line_idx, mut first_sub_row) = line_at_visual_row(self.lines, self.scroll, width);
let mut vis_y: u16 = 0;
while vis_y < area.height {
let Some(line) = self.lines.get(line_idx) else {
break;
};
let skip_rows = first_sub_row;
let rows_used = render_line_from_visual(line, area, buf, vis_y, true, skip_rows);
if rows_used == 0 {
break;
}
if let Some(((s_line, s_col), (e_line, e_col))) = sel_range {
if line_idx >= s_line && line_idx <= e_line {
let start_col = if line_idx == s_line {
s_col
} else {
band_cols.map_or(0, |c| c.0)
};
let end_col = if line_idx == e_line {
e_col
} else {
band_cols.map_or_else(
|| line.spans.iter().map(|s| s.content.chars().count()).sum(),
|c| c.1,
)
};
paint_preview_selection(
line, buf, area, vis_y, rows_used, width, skip_rows, start_col, end_col,
sel_style,
);
}
}
vis_y = vis_y.saturating_add(rows_used.max(1));
line_idx += 1;
first_sub_row = 0;
}
}
}
fn line_at_visual_row(lines: &[Line<'static>], visual_row: usize, width: usize) -> (usize, usize) {
let mut acc = 0usize;
for (idx, line) in lines.iter().enumerate() {
let rows = super::line_render::visual_rows_for_line(line, width).max(1);
if visual_row < acc + rows {
return (idx, visual_row - acc);
}
acc += rows;
}
(lines.len(), 0)
}
#[allow(clippy::too_many_arguments)]
fn paint_preview_selection(
line: &Line<'_>,
buf: &mut Buffer,
area: Rect,
y_first: u16,
rows_used: u16,
width: usize,
skip_rows: usize,
start_col: usize,
end_col: usize,
sel_style: Style,
) {
if width == 0 || end_col <= start_col {
return;
}
let chars: Vec<(char, Style)> = line
.spans
.iter()
.flat_map(|span| {
let style = span.style;
span.content.chars().map(move |c| (c, style))
})
.collect();
let indent = super::line_render::compute_hanging_indent(line);
let rows = visual_rows_of_chars(&chars, width, indent);
for (painted_off, (row_off, &(row_start, row_end, _))) in
rows.iter().enumerate().skip(skip_rows).enumerate()
{
if painted_off as u16 >= rows_used {
break;
}
let y = y_first + painted_off as u16;
if y >= area.y + area.height {
break;
}
let row_sel_start = start_col.max(row_start);
let row_sel_end = end_col.min(row_end);
if row_sel_start >= row_sel_end {
continue;
}
let row_indent = if row_off == 0 { 0 } else { indent };
for i in row_sel_start..row_sel_end {
let x_off = row_indent + (i - row_start);
let x = area.x + x_off as u16;
if x >= area.x + area.width {
break;
}
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(cell.style().patch(sel_style));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Theme;
use crate::markdown::{parse, Renderer};
use ratatui::{backend::TestBackend, Terminal};
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
#[test]
fn wrapped_code_block_bg_fills_last_row() {
let theme = theme();
let long = "a".repeat(100);
let md = format!("```\n{}\n```\n", long);
let lines = Renderer::new(theme)
.with_code_wrap(true)
.render(&parse(&md));
let mut state = PreviewState::default();
let backend = TestBackend::new(80, 6);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
frame.render_stateful_widget(
PreviewView {
lines: &lines,
scroll: 0,
},
frame.area(),
&mut state,
);
})
.unwrap();
let tbuf = terminal.backend().buffer().clone();
let expected_bg = theme.code_block_text.bg;
let mut last_a_row: Option<u16> = None;
for y in 0..6u16 {
let row: String = (0..80)
.map(|x| {
tbuf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect();
if row.contains('a') {
last_a_row = Some(y);
}
}
let y = last_a_row.expect("code rows present");
for x in 0..80u16 {
let cell = tbuf.cell((x, y)).expect("cell in bounds");
assert_eq!(
cell.style().bg,
expected_bg,
"cell at column {} on last wrap row does not have the code bg",
x
);
}
}
#[test]
fn list_item_wrap_hangs_indent_after_marker() {
let theme = theme();
let lines = Renderer::new(theme).render(&parse("- alpha bravo charlie delta\n"));
let mut state = PreviewState::default();
let backend = TestBackend::new(12, 4);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
frame.render_stateful_widget(
PreviewView {
lines: &lines,
scroll: 0,
},
frame.area(),
&mut state,
);
})
.unwrap();
let tbuf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..12)
.map(|x| {
tbuf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect()
};
let r0 = row_text(0);
assert!(r0.starts_with("• "), "row 0 = {r0:?}");
let r1 = row_text(1);
assert_eq!(
&r1[..2],
" ",
"continuation row should be left-padded by indent: {r1:?}"
);
assert!(
r1.chars().nth(2).map(|c| c != ' ').unwrap_or(false),
"continuation row must have text starting at indent column: {r1:?}"
);
assert!(
r1.trim_end().chars().any(|c| c.is_alphabetic()),
"expected wrapped body on row 1: {r1:?}"
);
}
#[test]
fn list_item_wrap_hangs_indent_for_task_and_ordered() {
let theme = theme();
let lines = Renderer::new(theme).render(&parse("- [ ] alpha bravo charlie delta\n"));
let mut state = PreviewState::default();
let backend = TestBackend::new(16, 4);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
frame.render_stateful_widget(
PreviewView {
lines: &lines,
scroll: 0,
},
frame.area(),
&mut state,
);
})
.unwrap();
let tbuf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..16)
.map(|x| {
tbuf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect()
};
let r0 = row_text(0);
assert!(r0.starts_with("• [ ] "), "row 0 = {r0:?}");
let r1 = row_text(1);
assert_eq!(
&r1[..6],
" ",
"task continuation must be padded by 6 cells: {r1:?}"
);
assert!(
r1.chars().nth(6).map(|c| c != ' ').unwrap_or(false),
"row 1 must start text at col 6: {r1:?}"
);
}
#[test]
fn code_block_bg_extends_to_viewport_edge() {
let theme = theme();
let lines = Renderer::new(theme).render(&parse("```\nfoo\n```\n"));
let mut state = PreviewState::default();
let backend = TestBackend::new(100, 3);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
frame.render_stateful_widget(
PreviewView {
lines: &lines,
scroll: 0,
},
frame.area(),
&mut state,
);
})
.unwrap();
let tbuf = terminal.backend().buffer().clone();
let mut code_row: Option<u16> = None;
for y in 0..3 {
let row_text: String = (0..100)
.map(|x| {
tbuf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect();
if row_text.contains("foo") {
code_row = Some(y);
break;
}
}
let y = code_row.expect("code row should be present");
let expected_bg = theme.code_block_text.bg;
for x in 0..100u16 {
let cell = tbuf.cell((x, y)).expect("cell in bounds");
assert_eq!(
cell.style().bg,
expected_bg,
"cell at column {} does not have the code block background",
x
);
}
}
#[test]
fn visual_scroll_starts_inside_wrapped_line() {
let theme = theme();
let lines = Renderer::new(theme).render(&parse("abcdefghijklmnopqrstuvwxyz\n"));
let mut state = PreviewState::default();
let backend = TestBackend::new(10, 2);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
frame.render_stateful_widget(
PreviewView {
lines: &lines,
scroll: 1,
},
frame.area(),
&mut state,
);
})
.unwrap();
let row: String = (0..10u16)
.map(|x| {
terminal
.backend()
.buffer()
.cell((x, 0))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect();
assert_eq!(row, "klmnopqrst");
}
}