use ratatui::{
buffer::Buffer as TuiBuf,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
};
use crate::config::Theme;
use crate::editor::EditorState;
use crate::markdown::cell_overlay::CellOverlay;
use crate::markdown::table_layout::char_cells;
use crate::ui::line_render;
use crate::document::row_map::{self, RawPos};
use crate::document::wrap::{Indent, PaintedRows};
use crate::markdown::{ColOrigin, ContentKind};
#[cfg(test)]
pub(super) fn make_raw_line(raw_text: &str, theme: &Theme) -> Line<'static> {
make_raw_line_with_selection(raw_text, None, theme)
}
pub(super) fn make_raw_line_with_selection(
raw_text: &str,
selection_cols: Option<(usize, usize)>,
theme: &Theme,
) -> Line<'static> {
make_raw_line_over(raw_text, selection_cols, theme, theme.normal)
}
pub(super) fn make_raw_line_over(
raw_text: &str,
selection_cols: Option<(usize, usize)>,
theme: &Theme,
base: Style,
) -> Line<'static> {
let sel_style = theme.selection;
let chars: Vec<char> = raw_text.chars().collect();
let total = chars.len();
let mut spans: Vec<Span<'static>> = Vec::with_capacity(total);
for (i, ch) in chars.iter().enumerate() {
let mut style = base;
if matches!(selection_cols, Some((s, e)) if i >= s && i < e) {
style = style.patch(sel_style);
}
spans.push(Span::styled(ch.to_string(), style));
}
Line::from(spans).style(base)
}
pub(super) fn make_code_styled_body_line(
raw_text: &str,
selection_cols: Option<(usize, usize)>,
theme: &Theme,
) -> Line<'static> {
let base = theme.code_block_text;
let sel_style = theme.selection;
let chars: Vec<char> = raw_text.chars().collect();
let total = chars.len();
let mut spans: Vec<Span<'static>> = Vec::with_capacity(total);
for (i, ch) in chars.iter().enumerate() {
let mut style = base;
if matches!(selection_cols, Some((s, e)) if i >= s && i < e) {
style = style.patch(sel_style);
}
spans.push(Span::styled(ch.to_string(), style));
}
Line::from(spans).style(base)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Overlay {
Selection,
Match,
}
#[allow(clippy::too_many_arguments)]
pub(super) fn paint_byte_range_overlay(
editor: &EditorState,
buf: &mut TuiBuf,
area: Rect,
y_start: u16,
rows_used: u16,
skip_rows: usize,
rendered_line_idx: usize,
sel_start_byte: usize,
sel_end_byte: usize,
style: Style,
overlay: Overlay,
) {
let Some(block_byte) = editor
.parsed
.source_map
.original_byte_for_rendered_line(rendered_line_idx)
else {
return;
};
let Some(block_range) = editor.parsed.source_map.original_range_for_byte(block_byte) else {
return;
};
if block_range.end <= sel_start_byte || block_range.start >= sel_end_byte {
return;
}
let rope = editor.buffer.rope();
let block_end = block_range.end.min(rope.len_bytes());
if block_range.start >= block_end {
return;
}
let parsed = &editor.parsed;
let first_line = parsed.byte_to_line(block_range.start);
let Some(block_idx) = editor.parsed.source_map.block_for_byte(block_range.start) else {
return;
};
if editor.parsed.is_image_block(block_idx) {
return;
}
let rendered_span = editor.parsed.source_map.rendered_lines_for_block(block_idx);
let sub_idx_in_block = rendered_line_idx.saturating_sub(rendered_span.start);
let Some(line) = editor.parsed.lines.get(rendered_line_idx) else {
return;
};
let indent = editor.parsed.row_indent(rendered_line_idx);
let actual_rendered: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
let line_start = |l: usize| match l {
0 => block_range.start,
_ => parsed.line_start_byte(first_line + l),
};
let line_end = |l: usize| {
(parsed.line_start_byte(first_line + l) + parsed.source_line(first_line + l).len())
.min(block_end)
};
if let Some(hit) = row_map::table_row(parsed, block_idx, sub_idx_in_block) {
if !hit.cells {
return;
}
let raw_line_start_abs = line_start(hit.line);
let raw_line_end_abs = line_end(hit.line);
let Some(raw_line) = rope
.get_byte_slice(raw_line_start_abs.min(raw_line_end_abs)..raw_line_end_abs)
.map(String::from)
else {
return;
};
let line_sel_start = sel_start_byte.max(raw_line_start_abs);
let line_sel_end = sel_end_byte.min(raw_line_end_abs);
if line_sel_start >= line_sel_end {
return;
}
let col_at = |abs: usize| rope.byte_to_char(abs) - rope.byte_to_char(raw_line_start_abs);
for (rs, re) in crate::markdown::table_layout::table_raw_col_range_to_rendered_segments(
&raw_line,
hit.raw_col,
line,
col_at(line_sel_start),
col_at(line_sel_end),
hit.sub,
parsed.ref_labels(),
) {
paint_cols_on_line(
line, indent, buf, area, y_start, rows_used, skip_rows, rs, re, style,
);
}
return;
}
let Some(lines) = row_map::lines_of_row(&editor.parsed, block_idx, sub_idx_in_block) else {
return;
};
let (first, last) = (lines.start as usize, lines.end as usize - 1);
let row_start = line_start(first);
let row_end = line_end(last);
let (sel_s, sel_e) = (sel_start_byte.max(row_start), sel_end_byte.min(row_end));
if sel_s >= sel_e {
return;
}
let pos_at = |abs: usize| {
let line = parsed.byte_to_line(abs).saturating_sub(first_line);
let from = line_start(line).min(abs);
RawPos {
line,
col: rope.byte_to_char(abs) - rope.byte_to_char(from),
}
};
let origin = &editor.parsed.row_origins()[rendered_line_idx];
let ColOrigin::Content { kind, .. } = origin.cols else {
if overlay == Overlay::Match {
return;
}
paint_cols_on_line(
line,
indent,
buf,
area,
y_start,
rows_used,
skip_rows,
0,
actual_rendered,
style,
);
return;
};
let rendered =
|abs: usize| row_map::raw_to_rendered_col(parsed, block_idx, sub_idx_in_block, pos_at(abs));
let mut rend_start = if sel_start_byte <= row_start && kind != ContentKind::Verbatim {
Some(0)
} else {
rendered(sel_s)
};
let mut rend_end = rendered(sel_e).or((sel_end_byte >= row_end).then_some(actual_rendered));
if overlay == Overlay::Match && (rend_start.is_none() || rend_end.is_none()) {
let width = rope.byte_to_char(sel_e) - rope.byte_to_char(sel_s);
let guess =
row_map::raw_to_rendered_col_near(parsed, block_idx, sub_idx_in_block, pos_at(sel_s));
let end = (guess + width).min(actual_rendered);
rend_start = Some(end.saturating_sub(width));
rend_end = Some(end);
}
let (Some(rend_start), Some(rend_end)) = (rend_start, rend_end) else {
return;
};
if rend_start >= rend_end {
return;
}
paint_cols_on_line(
line, indent, buf, area, y_start, rows_used, skip_rows, rend_start, rend_end, style,
);
}
pub(crate) fn paint_search_overlays(
editor: &EditorState,
buf: &mut TuiBuf,
area: Rect,
theme: &Theme,
) {
let Some(search) = editor.search.as_ref() else {
return;
};
if editor.substitute_preview.is_some() {
return;
}
if search.matches.is_empty() || area.width == 0 || area.height == 0 {
return;
}
let source_len = editor.buffer.rope().len_bytes();
let width = area.width as usize;
let (mut line_idx, mut first_sub_row) =
editor.rendered_line_at_visual_row(editor.scroll, width.max(1));
let mut vis_y: u16 = 0;
while vis_y < area.height {
if line_idx >= editor.parsed.lines.len() {
break;
}
let rows = editor
.parsed
.visual_rows_for_line_at(line_idx, width)
.max(1);
let painted = rows
.saturating_sub(first_sub_row)
.min((area.height - vis_y) as usize);
if painted == 0 {
break;
}
let block_range = editor
.parsed
.source_map
.original_byte_for_rendered_line(line_idx)
.and_then(|b| editor.parsed.source_map.original_range_for_byte(b));
if let Some(block_range) = block_range {
let start = search
.matches
.partition_point(|m| m.end <= block_range.start);
for (i, m) in search.matches.iter().enumerate().skip(start) {
if m.start >= block_range.end {
break;
}
if m.end > source_len {
continue;
}
let style = if i == search.focused_idx {
theme.selection
} else {
theme.selection_muted
};
paint_byte_range_overlay(
editor,
buf,
area,
vis_y,
painted as u16,
first_sub_row,
line_idx,
m.start,
m.end,
style,
Overlay::Match,
);
}
}
vis_y += painted as u16;
line_idx += 1;
first_sub_row = 0;
}
}
pub(crate) fn paint_substitute_preview_overlays(
editor: &EditorState,
buf: &mut TuiBuf,
area: Rect,
theme: &Theme,
) {
let Some(preview) = editor.substitute_preview.as_ref() else {
return;
};
if preview.highlights.is_empty() || area.width == 0 || area.height == 0 {
return;
}
let source_len = editor.buffer.rope().len_bytes();
let width = area.width as usize;
let (mut line_idx, mut first_sub_row) =
editor.rendered_line_at_visual_row(editor.scroll, width.max(1));
let mut vis_y: u16 = 0;
while vis_y < area.height {
if line_idx >= editor.parsed.lines.len() {
break;
}
let rows = editor
.parsed
.visual_rows_for_line_at(line_idx, width)
.max(1);
let painted = rows
.saturating_sub(first_sub_row)
.min((area.height - vis_y) as usize);
if painted == 0 {
break;
}
let block_range = editor
.parsed
.source_map
.original_byte_for_rendered_line(line_idx)
.and_then(|b| editor.parsed.source_map.original_range_for_byte(b));
if let Some(block_range) = block_range {
let start = preview
.highlights
.partition_point(|r| r.end <= block_range.start);
for r in preview.highlights.iter().skip(start) {
if r.start >= block_range.end {
break;
}
if r.end > source_len {
continue;
}
paint_byte_range_overlay(
editor,
buf,
area,
vis_y,
painted as u16,
first_sub_row,
line_idx,
r.start,
r.end,
theme.selection,
Overlay::Match,
);
}
}
vis_y += painted as u16;
line_idx += 1;
first_sub_row = 0;
}
}
pub(crate) fn paint_yank_flash(editor: &EditorState, buf: &mut TuiBuf, area: Rect, theme: &Theme) {
let Some(flash) = editor.active_yank_flash() else {
return;
};
if area.width == 0 || area.height == 0 {
return;
}
let source_len = editor.buffer.rope().len_bytes();
if flash.start >= flash.end || flash.end > source_len {
return;
}
let width = area.width as usize;
let (mut line_idx, mut first_sub_row) =
editor.rendered_line_at_visual_row(editor.scroll, width.max(1));
let mut vis_y: u16 = 0;
while vis_y < area.height {
if line_idx >= editor.parsed.lines.len() {
break;
}
let rows = editor
.parsed
.visual_rows_for_line_at(line_idx, width)
.max(1);
let painted = rows
.saturating_sub(first_sub_row)
.min((area.height - vis_y) as usize);
if painted == 0 {
break;
}
let block_range = editor
.parsed
.source_map
.original_byte_for_rendered_line(line_idx)
.and_then(|b| editor.parsed.source_map.original_range_for_byte(b));
if let Some(block_range) = block_range {
if block_range.start < flash.end && block_range.end > flash.start {
paint_byte_range_overlay(
editor,
buf,
area,
vis_y,
painted as u16,
first_sub_row,
line_idx,
flash.start,
flash.end,
theme.selection,
Overlay::Selection,
);
}
}
vis_y += painted as u16;
line_idx += 1;
first_sub_row = 0;
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn paint_cols_on_line(
line: &Line<'_>,
indent: Indent,
buf: &mut TuiBuf,
area: Rect,
y_start: u16,
rows_used: u16,
skip_rows: usize,
start_col: usize,
end_col: usize,
sel_bg: Style,
) {
line_render::patch_char_cols(
line,
indent,
buf,
area,
area.y + y_start,
rows_used,
skip_rows,
start_col..end_col,
sel_bg,
);
}
pub(super) struct PaintedRow<'l, 'a> {
pub(super) line: &'l Line<'a>,
pub(super) indent: Indent,
pub(super) visual_y: u16,
pub(super) skip_rows: usize,
pub(super) rows_used: u16,
}
pub(super) fn overlay_raw_cell(
buf: &mut TuiBuf,
area: Rect,
row: &PaintedRow<'_, '_>,
overlay: &CellOverlay,
selection_cols: Option<(usize, usize)>,
theme: &Theme,
cursor: Option<Style>,
) {
let PaintedRow {
line,
indent,
visual_y,
skip_rows,
rows_used,
} = *row;
if visual_y >= area.height || area.width == 0 {
return;
}
let positions =
PaintedRows::new(line, indent, usize::from(area.width)).unwrapped_cell_positions();
let screen_cell = |col: usize| {
let (sub_row, x) = positions.get(col).copied().flatten()?;
let row = u16::try_from(sub_row.checked_sub(skip_rows)?).ok()?;
let y = visual_y
.checked_add(row)
.filter(|&y| row < rows_used && y < area.height)?;
let x = u16::try_from(x).ok().filter(|&x| x < area.width)?;
Some((area.x + x, area.y + y))
};
let base_style = Style {
bg: None,
..theme.normal
};
let mut chars = overlay.raw_text.chars();
let mut col = overlay.rendered_start;
let mut i = 0usize;
let mut glyph_cell = None;
while col < overlay.rendered_end {
let mut ch = chars.next().unwrap_or(' ');
let mut w = char_cells(ch);
if w == 0 {
if let Some(at) = glyph_cell {
line_render::append_zero_width(buf, at, ch);
}
i += 1;
continue;
}
let at = screen_cell(col);
let past_border = w > 1 && col + w > overlay.rendered_end;
let split = w > 1
&& !past_border
&& at
.zip(screen_cell(col + 1))
.is_none_or(|((x, y), next)| next != (x + 1, y));
let mut targets = [at, None];
if past_border {
(ch, w) = (' ', 1);
} else if split {
ch = ' ';
targets[1] = screen_cell(col + 1);
}
glyph_cell = at.filter(|_| !(past_border || split));
let mut style = base_style;
if matches!(selection_cols, Some((s, e)) if i >= s && i < e) {
style = style.patch(theme.selection);
}
if let Some(cursor_style) = cursor.filter(|_| overlay.cursor_in_cell == Some(i)) {
style = cursor_style;
}
for cell in targets.into_iter().flatten() {
let Some(cell) = buf.cell_mut(cell) else {
continue;
};
cell.modifier = Modifier::empty();
cell.set_char(ch);
cell.set_style(style);
}
col += w;
i += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Theme;
fn overlay(start: usize, end: usize, raw: &str, cursor: Option<usize>) -> CellOverlay {
CellOverlay {
rendered_start: start,
rendered_end: end,
raw_text: raw.to_owned(),
cursor_in_cell: cursor,
raw_cell_byte_start: 0,
left_pipe: start.saturating_sub(1),
raw_content: 0..raw.chars().count(),
}
}
fn painted(ov: &CellOverlay, cursor: Style) -> TuiBuf {
let area = Rect::new(0, 0, 10, 1);
let mut buf = TuiBuf::empty(area);
for x in 0..10 {
buf[(x, 0)].set_char('x');
}
let line = Line::from("x".repeat(10));
let row = PaintedRow {
line: &line,
indent: Indent::NONE,
visual_y: 0,
skip_rows: 0,
rows_used: 1,
};
overlay_raw_cell(
&mut buf,
area,
&row,
ov,
None,
&Theme::default(),
Some(cursor),
);
buf
}
fn painted_wrapped(ov: &CellOverlay, cursor: Style, skip_rows: usize) -> TuiBuf {
let area = Rect::new(0, 0, 10, 2);
let mut buf = TuiBuf::empty(area);
for y in 0..2 {
for x in 0..10 {
buf[(x, y)].set_char('x');
}
}
let line = Line::from("│ a │ b │");
let row = PaintedRow {
line: &line,
indent: Indent::NONE,
visual_y: 0,
skip_rows,
rows_used: 2 - skip_rows as u16,
};
overlay_raw_cell(
&mut buf,
area,
&row,
ov,
None,
&Theme::default(),
Some(cursor),
);
buf
}
#[test]
fn overlay_raw_cell_follows_the_rows_wrap() {
let cursor = Style::default().bg(ratatui::style::Color::Magenta);
let buf = painted_wrapped(&overlay(7, 12, " b ", Some(2)), cursor, 0);
assert_eq!(buf[(7, 0)].symbol(), " ");
assert_eq!(buf[(8, 0)].symbol(), "x", "past the first row's text");
assert_eq!(buf[(0, 1)].symbol(), "b");
assert_eq!(buf[(1, 1)].bg, ratatui::style::Color::Magenta);
assert_eq!(buf[(3, 1)].symbol(), " ");
assert_eq!(
buf[(4, 1)].symbol(),
"x",
"the border cell is not the overlay's"
);
let buf = painted_wrapped(&overlay(7, 12, " b ", Some(2)), cursor, 1);
assert_eq!(buf[(0, 0)].symbol(), "b");
assert_eq!(buf[(7, 0)].symbol(), "x", "the first row is scrolled off");
}
#[test]
fn overlay_raw_cell_blanks_a_glyph_the_wrap_splits() {
let ov = overlay(7, 12, "日x", None);
let buf = painted_wrapped(&ov, Style::default(), 0);
assert_eq!(buf[(7, 0)].symbol(), " ");
assert_eq!(buf[(0, 1)].symbol(), " ");
assert_eq!(buf[(1, 1)].symbol(), "x");
let shown = (0..2).flat_map(|y| (0..10).map(move |x| (x, y)));
assert!(shown.into_iter().all(|c| buf[c].symbol() != "日"));
let buf = painted_wrapped(&ov, Style::default(), 1);
assert_eq!(buf[(0, 0)].symbol(), " ");
assert_eq!(buf[(1, 0)].symbol(), "x");
}
#[test]
fn overlay_raw_cell_advances_by_cells() {
let cursor = Style::default().bg(ratatui::style::Color::Magenta);
let buf = painted(&overlay(1, 6, "日a", Some(1)), cursor);
assert_eq!(buf[(1, 0)].symbol(), "日");
assert_eq!(buf[(3, 0)].symbol(), "a");
assert_eq!(buf[(3, 0)].bg, ratatui::style::Color::Magenta);
assert_eq!(buf[(4, 0)].symbol(), " ");
assert_eq!(buf[(5, 0)].symbol(), " ");
assert_eq!(
buf[(6, 0)].symbol(),
"x",
"the border cell is not the overlay's"
);
}
#[test]
fn overlay_raw_cell_blanks_a_glyph_that_would_straddle_the_border() {
let buf = painted(&overlay(1, 4, "ab日", None), Style::default());
assert_eq!(buf[(3, 0)].symbol(), " ");
assert_eq!(buf[(4, 0)].symbol(), "x");
}
#[test]
fn overlay_raw_cell_joins_zero_width_chars_to_the_preceding_glyph() {
let buf = painted(&overlay(1, 6, "e\u{0301}a", None), Style::default());
assert_eq!(buf[(1, 0)].symbol(), "e\u{0301}");
assert_eq!(buf[(2, 0)].symbol(), "a");
}
#[test]
fn overlay_raw_cell_drops_the_marks_of_a_glyph_blanked_at_the_border() {
let buf = painted(&overlay(1, 3, "a\u{304B}\u{3099}", None), Style::default());
assert_eq!(buf[(1, 0)].symbol(), "a");
assert_eq!(buf[(2, 0)].symbol(), " ");
}
#[test]
fn a_chrome_row_washes_for_a_selection_and_not_for_a_match() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let src = "```rust\nx\n```\n";
let editor = EditorState::new(crate::document::Buffer::from_str(src), theme);
let style = Style::default().bg(ratatui::style::Color::Rgb(9, 8, 7));
let washed = |overlay: Overlay| {
let area = Rect::new(0, 0, 20, 1);
let mut buf = TuiBuf::empty(area);
paint_byte_range_overlay(&editor, &mut buf, area, 0, 1, 0, 0, 3, 7, style, overlay);
(0..area.width)
.filter(|&x| buf[(x, 0)].bg == ratatui::style::Color::Rgb(9, 8, 7))
.count()
};
assert_eq!(washed(Overlay::Selection), " rust ".len());
assert_eq!(washed(Overlay::Match), 0);
}
#[test]
fn an_unmapped_row_shows_a_match_and_not_a_partial_selection() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let src = "x[^ss] y\n\n[^ß]: n\n";
let editor = EditorState::new(crate::document::Buffer::from_str(src), theme);
let style = Style::default().bg(ratatui::style::Color::Rgb(9, 8, 7));
let y = src.find('y').unwrap();
let painted = |overlay: Overlay| {
let area = Rect::new(0, 0, 20, 1);
let mut buf = TuiBuf::empty(area);
paint_byte_range_overlay(
&editor,
&mut buf,
area,
0,
1,
0,
0,
y,
y + 1,
style,
overlay,
);
(0..area.width)
.filter(|&x| buf[(x, 0)].bg == ratatui::style::Color::Rgb(9, 8, 7))
.count()
};
assert_eq!(painted(Overlay::Match), 1);
assert_eq!(painted(Overlay::Selection), 0);
}
#[test]
fn make_raw_line_keeps_source_text_verbatim() {
let theme = Theme::default();
let line = make_raw_line("hello", &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "hello");
}
#[test]
fn make_raw_line_with_selection_paints_range() {
let theme = Theme::default();
let line = make_raw_line_with_selection("hello", Some((1, 3)), &theme);
assert_eq!(line.spans[1].style.bg, theme.selection.bg);
assert_eq!(line.spans[2].style.bg, theme.selection.bg);
assert_ne!(line.spans[0].style.bg, theme.selection.bg);
}
}