#![allow(dead_code)]
use std::collections::HashMap;
use unicode_width::UnicodeWidthChar;
use crate::markdown::{DocBlock, TextBlockId};
use crate::ui::markdown_view::WrappedTextLayout;
pub fn byte_offset_to_block(blocks: &[DocBlock], byte: usize) -> usize {
assert!(
!blocks.is_empty(),
"byte_offset_to_block: blocks must not be empty"
);
let starts: Vec<u32> = blocks.iter().map(block_byte_start).collect();
match starts.binary_search(&crate::cast::u32_sat(byte)) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
}
}
pub fn byte_to_visual(
blocks: &[DocBlock],
text_layouts: &HashMap<TextBlockId, WrappedTextLayout>,
byte: usize,
) -> Option<(u32, u16)> {
if blocks.is_empty() {
return None;
}
let block_idx = byte_offset_to_block(blocks, byte);
let block = &blocks[block_idx];
let block_visual_start: u32 = blocks[..block_idx].iter().map(|b| b.height()).sum();
match block {
DocBlock::Text {
id,
text,
source_lines,
..
} => {
let layout = text_layouts.get(id)?;
let byte_start = block_byte_start(block) as usize;
let byte_within_block = byte.saturating_sub(byte_start);
let (logical_idx, col_in_line) = byte_within_block_to_logical(text, byte_within_block);
let physical_row = logical_to_first_physical_row(layout, logical_idx);
let visual_row = block_visual_start + physical_row;
let display_col = byte_col_to_display_col(text.lines.get(logical_idx)?, col_in_line);
let _ = source_lines;
Some((visual_row, display_col))
}
DocBlock::Mermaid { .. } | DocBlock::Table(_) => None,
}
}
pub fn visual_to_byte(
blocks: &[DocBlock],
text_layouts: &HashMap<TextBlockId, WrappedTextLayout>,
visual_row: u32,
visual_col: u16,
) -> Option<usize> {
let mut offset = 0u32;
for block in blocks {
let h = block.height();
if visual_row < offset + h {
let local_visual = (visual_row - offset) as usize;
return match block {
DocBlock::Text { id, text, .. } => {
let layout = text_layouts.get(id)?;
let logical_idx = layout
.physical_to_logical
.get(local_visual)
.copied()
.unwrap_or(0) as usize;
let line = text.lines.get(logical_idx)?;
let byte_col = display_col_to_byte_col(line, visual_col);
let byte_before = bytes_before_logical(text, logical_idx);
let block_byte_start = block_byte_start(block) as usize;
Some(block_byte_start + byte_before + byte_col)
}
DocBlock::Mermaid { .. } | DocBlock::Table(_) => None,
};
}
offset += h;
}
None
}
fn block_byte_start(block: &DocBlock) -> u32 {
match block {
DocBlock::Text {
source_byte_start, ..
} => *source_byte_start,
DocBlock::Mermaid {
source_byte_start, ..
} => *source_byte_start,
DocBlock::Table(t) => t.source_byte_start,
}
}
fn byte_within_block_to_logical(
text: &ratatui::text::Text<'static>,
byte_within_block: usize,
) -> (usize, usize) {
let mut remaining = byte_within_block;
for (i, line) in text.lines.iter().enumerate() {
let line_bytes: usize = line.spans.iter().map(|s| s.content.len()).sum();
let line_len_with_sep = if i + 1 < text.lines.len() {
line_bytes + 1
} else {
line_bytes
};
if remaining <= line_bytes {
return (i, remaining);
}
remaining = remaining.saturating_sub(line_len_with_sep);
}
let last = text.lines.len().saturating_sub(1);
let last_len: usize = text
.lines
.get(last)
.map_or(0, |l| l.spans.iter().map(|s| s.content.len()).sum());
(last, last_len.min(remaining))
}
fn bytes_before_logical(text: &ratatui::text::Text<'static>, logical_idx: usize) -> usize {
text.lines[..logical_idx]
.iter()
.map(|line| {
let line_bytes: usize = line.spans.iter().map(|s| s.content.len()).sum();
line_bytes + 1
})
.sum()
}
fn logical_to_first_physical_row(layout: &WrappedTextLayout, logical_idx: usize) -> u32 {
layout
.physical_to_logical
.iter()
.position(|&l| l == crate::cast::u32_sat(logical_idx))
.map_or(crate::cast::u32_sat(logical_idx), crate::cast::u32_sat)
}
fn byte_col_to_display_col(line: &ratatui::text::Line<'static>, byte_col: usize) -> u16 {
let mut bytes_consumed = 0usize;
let mut display_cols = 0u16;
'outer: for span in &line.spans {
for ch in span.content.chars() {
if bytes_consumed >= byte_col {
break 'outer;
}
bytes_consumed += ch.len_utf8();
display_cols =
display_cols.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0) as u16);
}
}
display_cols
}
fn display_col_to_byte_col(line: &ratatui::text::Line<'static>, display_col: u16) -> usize {
let mut cols_remaining = display_col as usize;
let mut byte_col = 0usize;
'outer: for span in &line.spans {
for ch in span.content.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if w > cols_remaining {
break 'outer;
}
cols_remaining = cols_remaining.saturating_sub(w);
byte_col += ch.len_utf8();
}
}
byte_col
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown::{DocBlock, update_text_layouts};
use crate::theme::{Palette, Theme};
use ratatui::text::{Line, Span, Text};
use std::cell::Cell;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn palette() -> Palette {
Palette::from_theme(Theme::Default)
}
fn theme() -> Theme {
Theme::Default
}
fn text_block_id(lines: &[Line<'static>]) -> TextBlockId {
let mut h = DefaultHasher::new();
for line in lines {
for span in &line.spans {
span.content.hash(&mut h);
}
}
lines.len().hash(&mut h);
TextBlockId(h.finish())
}
fn make_text_block(content: Vec<(&str, u32)>) -> DocBlock {
let lines: Vec<Line<'static>> = content
.iter()
.map(|(s, _)| Line::from(Span::raw(s.to_string())))
.collect();
let source_lines: Vec<u32> = content.iter().map(|(_, l)| *l).collect();
let n = lines.len();
let id = text_block_id(&lines);
DocBlock::Text {
id,
text: Text::from(lines),
links: vec![],
heading_anchors: vec![],
source_lines,
wrapped_height: Cell::new(crate::cast::u32_sat(n)),
source_byte_start: 0,
source_byte_end: 0,
}
}
fn sample_doc() -> (String, Vec<DocBlock>) {
let md = "Para one.\n\n## Heading\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n```mermaid\ngraph LR\nA-->B\n```\n\nFinal para.\n".to_string();
let p = palette();
let blocks = crate::markdown::renderer::render_markdown(&md, &p, theme());
(md, blocks)
}
#[test]
fn byte_offset_to_block_covers_full_source() {
let (source, blocks) = sample_doc();
assert!(!blocks.is_empty());
for byte in 0..source.len() {
let idx = byte_offset_to_block(&blocks, byte);
let b = &blocks[idx];
let start = block_byte_start(b) as usize;
let end = match b {
DocBlock::Text {
source_byte_end, ..
} => *source_byte_end as usize,
DocBlock::Mermaid {
source_byte_end, ..
} => *source_byte_end as usize,
DocBlock::Table(t) => t.source_byte_end as usize,
};
assert!(
start <= byte && byte < end,
"byte {byte} not in block[{idx}] range [{start}, {end})"
);
}
let _ = byte_offset_to_block(&blocks, source.len());
}
#[test]
fn byte_to_visual_round_trips_via_visual_to_byte() {
let (source, blocks) = sample_doc();
let mut text_layouts = HashMap::new();
update_text_layouts(&blocks, &mut text_layouts, 80);
for (idx, block) in blocks.iter().enumerate() {
let (start, end) = match block {
DocBlock::Text {
source_byte_start,
source_byte_end,
..
} => (*source_byte_start as usize, *source_byte_end as usize),
_ => continue,
};
let test_bytes: Vec<usize> = (start..end)
.step_by(((end - start) / 5).max(1))
.take(6)
.collect();
for byte in test_bytes {
if let Some((vrow, vcol)) = byte_to_visual(&blocks, &text_layouts, byte)
&& let Some(back) = visual_to_byte(&blocks, &text_layouts, vrow, vcol)
{
let back_idx = byte_offset_to_block(&blocks, back);
assert_eq!(
back_idx, idx,
"round-trip from byte {byte} ended in block {back_idx}, expected {idx}"
);
assert!(
back < source.len() + 1,
"round-trip byte {back} out of range"
);
}
}
}
}
#[test]
fn text_block_id_stable_under_source_line_shift() {
let lines = vec![
Line::from(Span::raw("Hello world")),
Line::from(Span::raw("Second line")),
];
let source_lines_a = vec![0u32, 1];
let source_lines_b = vec![10u32, 11];
let id_a = {
let mut h = DefaultHasher::new();
for line in &lines {
for span in &line.spans {
span.content.hash(&mut h);
}
}
lines.len().hash(&mut h);
TextBlockId(h.finish())
};
let id_b = {
let mut h = DefaultHasher::new();
for line in &lines {
for span in &line.spans {
span.content.hash(&mut h);
}
}
lines.len().hash(&mut h);
TextBlockId(h.finish())
};
let _ = (source_lines_a, source_lines_b);
assert_eq!(
id_a, id_b,
"TextBlockId must be identical for same content at different source line numbers"
);
}
#[test]
fn text_block_id_changes_under_content_change() {
let lines_a = vec![Line::from(Span::raw("Content A"))];
let lines_b = vec![Line::from(Span::raw("Content B"))];
let id_a = {
let mut h = DefaultHasher::new();
for line in &lines_a {
for span in &line.spans {
span.content.hash(&mut h);
}
}
lines_a.len().hash(&mut h);
TextBlockId(h.finish())
};
let id_b = {
let mut h = DefaultHasher::new();
for line in &lines_b {
for span in &line.spans {
span.content.hash(&mut h);
}
}
lines_b.len().hash(&mut h);
TextBlockId(h.finish())
};
assert_ne!(id_a, id_b, "TextBlockId must differ for different content");
}
#[test]
fn block_byte_ranges_contiguous_post_fixup() {
let (source, blocks) = sample_doc();
assert!(!blocks.is_empty(), "expected at least one block");
assert_eq!(
block_byte_start(&blocks[0]),
0,
"first block must start at byte 0"
);
for i in 0..blocks.len().saturating_sub(1) {
let this_end = match &blocks[i] {
DocBlock::Text {
source_byte_end, ..
} => *source_byte_end,
DocBlock::Mermaid {
source_byte_end, ..
} => *source_byte_end,
DocBlock::Table(t) => t.source_byte_end,
};
let next_start = block_byte_start(&blocks[i + 1]);
assert_eq!(
this_end,
next_start,
"block[{i}].source_byte_end ({this_end}) != block[{}].source_byte_start ({next_start})",
i + 1
);
}
let last_end = match blocks.last().unwrap() {
DocBlock::Text {
source_byte_end, ..
} => *source_byte_end,
DocBlock::Mermaid {
source_byte_end, ..
} => *source_byte_end,
DocBlock::Table(t) => t.source_byte_end,
};
assert_eq!(
last_end as usize,
source.len(),
"last block must end at source.len() = {}",
source.len()
);
}
#[test]
fn byte_to_visual_returns_none_for_mermaid_block() {
let md = "```mermaid\ngraph LR\nA-->B\n```\n";
let p = palette();
let blocks = crate::markdown::renderer::render_markdown(md, &p, theme());
let text_layouts = HashMap::new();
let mermaid_block = blocks
.iter()
.find(|b| matches!(b, DocBlock::Mermaid { .. }));
if let Some(b) = mermaid_block {
let start = block_byte_start(b) as usize;
assert!(
byte_to_visual(&blocks, &text_layouts, start).is_none(),
"byte_to_visual must return None for Mermaid blocks"
);
}
}
#[test]
fn byte_offset_to_block_single_block() {
let md = "Hello world.\n";
let p = palette();
let blocks = crate::markdown::renderer::render_markdown(md, &p, theme());
for byte in 0..md.len() {
assert_eq!(
byte_offset_to_block(&blocks, byte),
0,
"byte {byte} must resolve to block 0 in a single-block document"
);
}
}
}