use ratatui::text::Line;
use crate::document::CellBand;
use crate::editor::table_edit;
use crate::editor::{EditorState, Mode};
use crate::markdown::list_layout::{
list_rendered_col_to_raw_col_marker, raw_list_marker_char_width,
rendered_list_marker_char_width,
};
use crate::markdown::table_layout;
use crate::ui::line_render;
use crate::ui::table_view::HEADER_ROWS;
pub(super) fn rendered_line_at_row(
state: &EditorState,
row: usize,
) -> Option<(Line<'static>, usize)> {
let lines = &state.parsed.lines;
if lines.is_empty() {
return None;
}
let (mut line_idx, mut first_sub_row) =
state.rendered_line_at_visual_row(state.scroll.saturating_add(row), state.viewport_width);
let mut y = 0usize;
while let Some(line) = lines.get(line_idx) {
let rows_used = line_render::visual_rows_for_line(line, state.viewport_width).max(1);
let visible_rows = rows_used.saturating_sub(first_sub_row).max(1);
if y < visible_rows {
return Some((line.clone(), first_sub_row));
}
y += visible_rows;
line_idx += 1;
first_sub_row = 0;
}
None
}
pub(super) fn click_to_char_offset(
state: &EditorState,
col: usize,
row: usize,
viewport_width: usize,
) -> Option<usize> {
match state.mode {
Mode::Raw => Some(raw_click_to_offset(state, col, row, viewport_width)),
Mode::Preview | Mode::Rendered => {
Some(rendered_click_to_offset(state, col, row, viewport_width))
}
Mode::Diff => None,
}
}
fn raw_click_to_offset(
state: &EditorState,
col: usize,
row: usize,
viewport_width: usize,
) -> usize {
let line_count = state.buffer.line_count();
let width = viewport_width.max(1);
let (mut target_line, mut first_sub_row) = state.raw_line_at_visual_row(state.scroll, width);
let mut y = 0usize;
while target_line < line_count {
let text = state
.buffer
.line(target_line)
.map(|s| s.trim_end_matches('\n').to_owned())
.unwrap_or_default();
let rows = line_render::visual_rows_of_str(&text, width);
let used = rows.len().max(1).saturating_sub(first_sub_row).max(1);
if row < y + used {
let sub_row = first_sub_row + row - y;
let line_start = state.buffer.line_to_char(target_line);
let row_tuple = rows.get(sub_row).copied().unwrap_or((0, 0, 0));
let raw_col = char_in_row_at_cell(&text, row_tuple, col, 0, sub_row + 1 == rows.len());
return line_start + raw_col;
}
y += used;
target_line += 1;
first_sub_row = 0;
}
state.buffer.len_chars()
}
fn char_in_row_at_cell(
text: &str,
row: (usize, usize, usize),
target_cell: usize,
indent: usize,
is_last_row: bool,
) -> usize {
let (start, end, next_start) = row;
let max_char_in_row = if is_last_row {
end
} else {
next_start.saturating_sub(1).max(start)
};
let row_chars = text.chars().skip(start).take(end - start);
let in_row = line_render::char_idx_at_cell_col(row_chars, target_cell, indent);
(start + in_row).min(max_char_in_row)
}
fn rendered_click_to_offset(
state: &EditorState,
col: usize,
row: usize,
viewport_width: usize,
) -> usize {
match walk_rendered_rows(state, row, viewport_width) {
Some((idx, sub_row)) => {
rendered_sub_line_to_offset(state, idx, sub_row, col, viewport_width)
}
None => state.buffer.len_chars(),
}
}
pub(super) fn rendered_click_to_line_col(
state: &EditorState,
col: usize,
row: usize,
viewport_width: usize,
) -> Option<(usize, usize)> {
let (idx, sub_row) = walk_rendered_rows(state, row, viewport_width)?;
let line = state.parsed.lines.get(idx)?;
let chars: Vec<(char, ratatui::style::Style)> = line
.spans
.iter()
.flat_map(|span| {
let style = span.style;
span.content.chars().map(move |c| (c, style))
})
.collect();
if chars.is_empty() {
return Some((idx, 0));
}
let indent = line_render::compute_hanging_indent(line);
let rows = line_render::visual_rows_of_chars(&chars, viewport_width.max(1), indent);
let sub = sub_row.min(rows.len().saturating_sub(1));
let (row_start, row_end, _) = rows
.get(sub)
.copied()
.unwrap_or((0, chars.len(), chars.len()));
let row_indent = if sub == 0 { 0 } else { indent };
let local_col = col.saturating_sub(row_indent);
let char_col = (row_start + local_col).min(row_end);
Some((idx, char_col))
}
fn walk_rendered_rows(
state: &EditorState,
row: usize,
viewport_width: usize,
) -> Option<(usize, usize)> {
let lines = &state.parsed.lines;
if lines.is_empty() {
return None;
}
let (mut idx, mut first_sub_row) =
state.rendered_line_at_visual_row(state.scroll, viewport_width);
let mut y = 0usize;
while idx < lines.len() {
let rows_used = revealed_raw_row_count(state, idx, viewport_width)
.unwrap_or_else(|| state.parsed.visual_rows_for_line_at(idx, viewport_width))
.max(1);
let used = rows_used.saturating_sub(first_sub_row).max(1);
if row < y + used {
return Some((idx, first_sub_row + row - y));
}
y += used;
idx += 1;
first_sub_row = 0;
}
None
}
pub fn rendered_sub_line_to_offset(
state: &EditorState,
rendered_line_idx: usize,
sub_row_within_line: usize,
col: usize,
viewport_width: usize,
) -> usize {
let buffer_len = state.buffer.len_chars();
let source = state.buffer.contents();
let Some(block) = locate_block(state, rendered_line_idx) else {
return buffer_len;
};
let block_text = source
.get(block.range.start..block.range.end.min(source.len()))
.unwrap_or("");
let is_table = table_edit::is_table_block(block_text);
let mut table_sub = 0usize;
let raw_line_idx = if is_table {
let (raw_idx, sub) = table_raw_line_idx(state, &block, block_text);
table_sub = sub;
raw_idx
} else if state.parsed.is_image_block(block.idx) && !state.parsed.is_mermaid_block(block.idx) {
0
} else {
block.sub_idx
};
if block_text.is_empty() {
return state.buffer.rope().byte_to_char(block.range.start);
}
let (line_byte_start, line_byte_end) = raw_line_byte_range(block_text, raw_line_idx);
let line_text = &block_text[line_byte_start..line_byte_end];
let rendered_line = &state.parsed.lines[rendered_line_idx];
let block_kind = state.parsed.real_block_for_byte(block.range.start);
let content_lines = crate::ui::rendered_view::raw_source_lines(block_text);
let line_reveals = crate::markdown::code_layout::line_allows_raw_reveal(
block_kind,
raw_line_idx,
&content_lines,
);
let revealed_cursor_line = !is_table
&& line_reveals
&& state.cursor_block_revealed()
&& rendered_line_idx == crate::editor::state::cursor_rendered_line_idx(state);
if state.parsed.is_mermaid_block(block.idx) || revealed_cursor_line {
let (rows, indent) = revealed_raw_rows(line_text, viewport_width);
let sub = sub_row_within_line.min(rows.len().saturating_sub(1));
let (start, end, next_start) = rows.get(sub).copied().unwrap_or((0, 0, 0));
let is_last_row = sub + 1 == rows.len();
let max_in_row = if is_last_row {
end
} else {
next_start.saturating_sub(1).max(start)
};
let row_indent = if sub == 0 { 0 } else { indent };
let row_chars = line_text.chars().skip(start).take(end - start);
let in_row = line_render::char_idx_at_cell_col(row_chars, col, row_indent);
let raw_col = (start + in_row).min(max_in_row);
return raw_col_to_buffer_char(state, &block, line_byte_start, line_text, raw_col);
}
let raw_col = if is_table && rendered_line.spans.iter().any(|s| s.content.contains('│')) {
let row_width = line_row_width(rendered_line, sub_row_within_line);
let clamped_col = col.min(row_width);
table_click_to_raw_col(line_text, rendered_line, clamped_col, table_sub)
.unwrap_or(clamped_col)
} else if let Some(crate::markdown::Block::CodeBlock { fenced, .. }) =
block_kind.filter(|_| !line_reveals)
{
let rendered_chars: Vec<(char, ratatui::style::Style)> = rendered_line
.spans
.iter()
.flat_map(|span| span.content.chars().map(move |c| (c, span.style)))
.collect();
let rendered_idx = click_to_rendered_char_idx(
rendered_line,
&rendered_chars,
col,
sub_row_within_line,
viewport_width,
);
let stripped = line_text.strip_suffix('\n').unwrap_or(line_text);
crate::markdown::code_layout::code_rendered_col_to_raw_col(stripped, *fenced, rendered_idx)
} else {
let buffer_line_idx = state
.buffer
.block_line_to_buffer_line(block.range.start, raw_line_idx);
let stripped = line_text.strip_suffix('\n').unwrap_or(line_text);
let inline_map = state.inline_map_for(buffer_line_idx, stripped);
let is_list = matches!(
state
.parsed
.real_block_for_byte(block.range.start + line_byte_start),
Some(crate::markdown::Block::List { .. })
);
non_table_click_to_raw_col(
rendered_line,
line_text,
col,
sub_row_within_line,
viewport_width,
&inline_map,
is_list,
)
};
raw_col_to_buffer_char(state, &block, line_byte_start, line_text, raw_col)
}
struct BlockLocation {
idx: usize,
range: std::ops::Range<usize>,
rendered_span: std::ops::Range<usize>,
sub_idx: usize,
}
fn locate_block(state: &EditorState, rendered_line_idx: usize) -> Option<BlockLocation> {
let block_start_byte = state
.parsed
.source_map
.original_byte_for_rendered_line(rendered_line_idx)?;
let idx = state.parsed.source_map.block_for_byte(block_start_byte)?;
let range = state
.parsed
.source_map
.original_range_for_byte(block_start_byte)?;
let rendered_span = state
.parsed
.source_map
.rendered_lines_for_byte(block_start_byte);
let sub_idx = rendered_line_idx.saturating_sub(rendered_span.start);
Some(BlockLocation {
idx,
range,
rendered_span,
sub_idx,
})
}
fn table_raw_line_idx(
state: &EditorState,
block: &BlockLocation,
block_text: &str,
) -> (usize, usize) {
use crate::ui::table_view::TableSubLineKind;
let block_lines = state
.parsed
.lines
.get(block.rendered_span.start..block.rendered_span.end.min(state.parsed.lines.len()))
.unwrap_or(&[]);
let kinds = crate::ui::table_view::classify_table_sub_lines(block_lines);
match kinds.get(block.sub_idx) {
Some(TableSubLineKind::TopBorder) => (0, 0),
Some(TableSubLineKind::Header { sub }) => (0, *sub),
Some(TableSubLineKind::ThickSeparator) => (HEADER_ROWS, 0),
Some(TableSubLineKind::DataRow { row, sub }) => (row + HEADER_ROWS, *sub),
Some(TableSubLineKind::ThinSeparator) => {
let row = kinds[..block.sub_idx]
.iter()
.rev()
.find_map(|k| {
if let TableSubLineKind::DataRow { row, .. } = k {
Some(*row)
} else {
None
}
})
.unwrap_or(0);
(row + HEADER_ROWS, 0)
}
Some(TableSubLineKind::BottomBorder) | None => {
let last_data = block_text.split('\n').count().saturating_sub(HEADER_ROWS);
(last_data.max(HEADER_ROWS), 0)
}
}
}
fn revealed_raw_row_count(
state: &EditorState,
rendered_line_idx: usize,
viewport_width: usize,
) -> Option<usize> {
if !state.cursor_block_revealed() {
return None;
}
let cursor_block_idx = state.cursor_block_idx?;
let block_lines = state
.parsed
.source_map
.rendered_lines_for_block(cursor_block_idx);
if !block_lines.contains(&rendered_line_idx) {
return None;
}
let block_start_byte = state
.parsed
.source_map
.original_byte_for_rendered_line(block_lines.start)?;
let block_range = state
.parsed
.source_map
.original_range_for_byte(block_start_byte)?;
let source = state.buffer.contents();
let block_text = source
.get(block_range.start..block_range.end.min(source.len()))
.unwrap_or("");
if state.parsed.is_mermaid_block(cursor_block_idx) {
let sub = rendered_line_idx - block_lines.start;
let raw_line = block_text.split('\n').nth(sub).unwrap_or("");
return Some(revealed_raw_rows(raw_line, viewport_width).0.len().max(1));
}
let is_table = table_edit::is_table_block(block_text);
if is_table {
return None;
}
let cursor_line = crate::editor::state::cursor_rendered_line_idx(state);
if rendered_line_idx != cursor_line {
return None;
}
let sub = rendered_line_idx - block_lines.start;
let raw_line = block_text.split('\n').nth(sub).unwrap_or("");
if !crate::markdown::code_layout::line_allows_raw_reveal(
state.parsed.real_block_for_byte(block_range.start),
sub,
&crate::ui::rendered_view::raw_source_lines(block_text),
) {
return None;
}
Some(revealed_raw_rows(raw_line, viewport_width).0.len().max(1))
}
fn revealed_raw_rows(raw_line: &str, viewport_width: usize) -> (Vec<(usize, usize, usize)>, usize) {
let width = viewport_width.max(1);
let indent = line_render::compute_hanging_indent_str(raw_line);
let indent = if indent + 1 >= width { 0 } else { indent };
let chars: Vec<(char, ratatui::style::Style)> = raw_line
.chars()
.map(|c| (c, ratatui::style::Style::default()))
.collect();
(
line_render::visual_rows_of_chars(&chars, width, indent),
indent,
)
}
fn raw_line_byte_range(block_text: &str, raw_line_idx: usize) -> (usize, usize) {
let mut byte_cursor = 0usize;
for (i, line) in block_text.split('\n').enumerate() {
if i == raw_line_idx {
return (byte_cursor, byte_cursor + line.len());
}
byte_cursor += line.len() + 1;
if byte_cursor >= block_text.len() {
return (byte_cursor.saturating_sub(line.len() + 1), block_text.len());
}
}
(0, block_text.len())
}
fn click_to_rendered_char_idx(
rendered_line: &Line<'_>,
rendered_chars: &[(char, ratatui::style::Style)],
col: usize,
sub_row_within_line: usize,
viewport_width: usize,
) -> usize {
let indent = line_render::compute_hanging_indent(rendered_line);
let viewport = viewport_width.max(1);
let rows = line_render::visual_rows_of_chars(rendered_chars, viewport, indent);
let sub = sub_row_within_line.min(rows.len().saturating_sub(1));
let (start, end, next_start) = rows.get(sub).copied().unwrap_or((0, 0, 0));
let row_indent = if sub == 0 { 0 } else { indent };
let is_last_row = sub + 1 == rows.len();
let max_in_row = if is_last_row {
end
} else {
next_start.saturating_sub(1).max(start)
};
let row_chars = rendered_chars
.iter()
.skip(start)
.take(end - start)
.map(|(c, _)| *c);
let in_row = line_render::char_idx_at_cell_col(row_chars, col, row_indent);
(start + in_row).min(max_in_row)
}
fn non_table_click_to_raw_col(
rendered_line: &Line<'_>,
line_text: &str,
col: usize,
sub_row_within_line: usize,
viewport_width: usize,
inline_map: &crate::markdown::InlineColMap,
is_list: bool,
) -> usize {
let rendered_chars: Vec<(char, ratatui::style::Style)> = rendered_line
.spans
.iter()
.flat_map(|span| span.content.chars().map(move |c| (c, span.style)))
.collect();
let rendered_idx = click_to_rendered_char_idx(
rendered_line,
&rendered_chars,
col,
sub_row_within_line,
viewport_width,
);
let actual_rendered_count = rendered_chars.len();
let map = inline_map.rendered_to_raw_vec();
let map_content_count = inline_map.rendered_len();
let raw_content_start = map.first().copied().unwrap_or(0);
if is_list {
if let (Some(rmw), Some(rmw_r)) = (
raw_list_marker_char_width(line_text),
rendered_list_marker_char_width(rendered_line),
) {
if rendered_idx < rmw_r {
return list_rendered_col_to_raw_col_marker(rmw, rmw_r, rendered_idx);
}
let content_idx = rendered_idx - rmw_r;
let content_rendered = actual_rendered_count.saturating_sub(rmw_r);
if map_content_count == content_rendered {
return map
.get(content_idx)
.copied()
.unwrap_or_else(|| line_text.chars().count());
}
return (rendered_idx + rmw).saturating_sub(rmw_r);
}
}
if actual_rendered_count >= map_content_count {
let prefix_len = actual_rendered_count - map_content_count;
if prefix_len <= raw_content_start {
return if rendered_idx < prefix_len {
rendered_idx.min(raw_content_start)
} else {
let content_idx = rendered_idx - prefix_len;
map.get(content_idx)
.copied()
.unwrap_or_else(|| line_text.chars().count())
};
}
}
rendered_idx
}
fn raw_col_to_buffer_char(
state: &EditorState,
block: &BlockLocation,
line_byte_start: usize,
line_text: &str,
raw_col: usize,
) -> usize {
let line_char_count = line_text.chars().count();
let raw_col = raw_col.min(line_char_count);
let byte_offset_in_line: usize = line_text.chars().take(raw_col).map(char::len_utf8).sum();
let byte_in_block = line_byte_start + byte_offset_in_line.min(line_text.len());
let block_text_len = block.range.end.saturating_sub(block.range.start);
let absolute_byte = block.range.start + byte_in_block.min(block_text_len);
let source_len = state.buffer.contents().len();
state
.buffer
.rope()
.byte_to_char(absolute_byte.min(source_len))
.min(state.buffer.len_chars())
}
fn table_click_to_raw_col(
raw_line: &str,
rendered_line: &Line<'_>,
rendered_col: usize,
sub: usize,
) -> Option<usize> {
let raw_pipes = table_layout::raw_pipe_positions(raw_line);
let rendered_pipes = table_layout::rendered_pipe_positions(rendered_line);
if raw_pipes.len() < 2 || rendered_pipes.len() != raw_pipes.len() {
return None;
}
let col_count = rendered_pipes.len() - 1;
let cell_idx = (0..col_count)
.find(|&i| rendered_col < rendered_pipes[i + 1])
.unwrap_or(col_count - 1);
let rend_cell_start = rendered_pipes[cell_idx] + 1;
let rend_cell_end = rendered_pipes[cell_idx + 1];
let raw_cell_start = raw_pipes[cell_idx] + 1;
let raw_cell_end = raw_pipes[cell_idx + 1];
let raw_cell_text: String = raw_line
.chars()
.skip(raw_cell_start)
.take(raw_cell_end - raw_cell_start)
.collect();
let clicked = rendered_col.max(rend_cell_start);
let rend_offset_in_cell = clicked.saturating_sub(rend_cell_start);
let raw_chars: Vec<char> = raw_cell_text.chars().collect();
let raw_leading = raw_chars.iter().take_while(|c| c.is_whitespace()).count();
let raw_trailing = raw_chars
.iter()
.rev()
.take_while(|c| c.is_whitespace())
.count();
let content_chars = raw_chars.len().saturating_sub(raw_leading + raw_trailing);
let cell_width = rend_cell_end.saturating_sub(rend_cell_start + 2).max(1);
let trimmed: String = raw_chars[raw_leading..raw_leading + content_chars]
.iter()
.collect();
let chunks = table_layout::wrap_cell_with_indices(&trimmed, cell_width);
let (chunk_start, chunk_len) = chunks
.get(sub)
.map(|(start, text)| (*start, text.chars().count()))
.unwrap_or((content_chars, 0));
let raw_offset_in_cell = if rend_offset_in_cell <= 1 {
raw_leading + chunk_start
} else {
let content_col = rend_offset_in_cell - 1;
raw_leading + chunk_start + content_col.min(chunk_len)
};
Some(raw_cell_start + raw_offset_in_cell.min(raw_chars.len()))
}
pub(super) fn preview_table_cell_band(
state: &EditorState,
rendered_line_idx: usize,
col: usize,
) -> Option<CellBand> {
use crate::ui::table_view::TableSubLineKind;
let block = locate_block(state, rendered_line_idx)?;
let source = state.buffer.contents();
let block_text = source.get(block.range.start..block.range.end.min(source.len()))?;
if !table_edit::is_table_block(block_text) {
return None;
}
let block_lines = state
.parsed
.lines
.get(block.rendered_span.start..block.rendered_span.end.min(state.parsed.lines.len()))?;
let kinds = crate::ui::table_view::classify_table_sub_lines(block_lines);
let same_row = |k: &TableSubLineKind| match (kinds.get(block.sub_idx), k) {
(Some(TableSubLineKind::Header { .. }), TableSubLineKind::Header { .. }) => true,
(
Some(TableSubLineKind::DataRow { row: clicked, .. }),
TableSubLineKind::DataRow { row, .. },
) => row == clicked,
_ => false,
};
if !kinds.get(block.sub_idx).is_some_and(&same_row) {
return None;
}
let mut first = block.sub_idx;
while first > 0 && same_row(&kinds[first - 1]) {
first -= 1;
}
let mut last = block.sub_idx;
while last + 1 < kinds.len() && same_row(&kinds[last + 1]) {
last += 1;
}
let pipes = table_layout::rendered_pipe_positions(&state.parsed.lines[rendered_line_idx]);
if pipes.len() < 2 {
return None;
}
let col_count = pipes.len() - 1;
let cell_idx = (0..col_count)
.find(|&i| col < pipes[i + 1])
.unwrap_or(col_count - 1);
let cols = (pipes[cell_idx] + 2, (pipes[cell_idx + 1]).saturating_sub(1));
if cols.0 >= cols.1 {
return None;
}
Some(CellBand {
lines: (
block.rendered_span.start + first,
block.rendered_span.start + last,
),
cols,
})
}
pub(super) fn table_cell_char_range_at(
state: &EditorState,
char_offset: usize,
) -> Option<(usize, usize)> {
let source = state.buffer.contents();
let byte = state
.buffer
.rope()
.char_to_byte(char_offset.min(state.buffer.len_chars()));
let info = table_edit::find_table_at(&source, byte)?;
let (row_idx, col_idx) = table_edit::cursor_cell(&info, byte)?;
let row = info.rows.get(row_idx)?;
if row.kind == table_edit::RowKind::Alignment {
return None;
}
let cell = row.cells.get(col_idx)?;
let start_byte = row.start + cell.content_start;
let end_byte = row.start + cell.content_end;
let rope = state.buffer.rope();
Some((
rope.byte_to_char(start_byte.min(source.len())),
rope.byte_to_char(end_byte.min(source.len())),
))
}
fn line_row_width(line: &Line<'_>, _sub_row: usize) -> usize {
line.spans.iter().map(|s| s.content.chars().count()).sum()
}