use std::ops::Range;
use ratatui::buffer::Buffer as TuiBuf;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use crate::config::Theme;
use crate::editor::{table_edit, EditorState};
use crate::markdown::table_layout;
pub const REORDER_HANDLE_GLYPH: char = '⠿';
pub const DROP_ROW_GLYPH: char = '━';
pub const DROP_COL_GLYPH: char = '┃';
pub const COLUMN_RESIZE_GLYPH: char = '⇔';
pub const DELETE_HANDLE_GLYPH: char = '✕';
pub const HEADER_ROWS: usize = 2;
#[derive(Debug, Clone)]
pub struct TableLayoutSnapshot {
pub table_byte_start: usize,
pub table_byte_end: usize,
pub col_count: usize,
pub row_count: usize,
pub col_ranges: Vec<Range<u16>>,
pub row_ranges: Vec<Range<u16>>,
pub row_handle_col: Option<u16>,
pub top_border_row: Option<u16>,
pub header_row: Option<u16>,
pub delete_row_handle_col: Option<u16>,
pub bottom_border_row: Option<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableHit {
Cell { row_idx: usize, col_idx: usize },
ColumnBorder { col_idx: usize },
RowHandle { row_idx: usize },
ColumnHandle { col_idx: usize },
DeleteRowHandle { row_idx: usize },
DeleteColumnHandle { col_idx: usize },
}
impl TableLayoutSnapshot {
pub fn data_row_at_y(&self, row: u16) -> Option<usize> {
let first = self.row_ranges.first()?;
let last = self.row_ranges.last()?;
if row < first.start.saturating_sub(1) || row > last.end {
return None;
}
let idx = self
.row_ranges
.iter()
.enumerate()
.min_by_key(|(_, r)| range_distance(r, row))
.map(|(i, _)| i)?;
Some(HEADER_ROWS + idx)
}
pub fn column_at_x(&self, col: u16) -> Option<usize> {
let first = self.col_ranges.first()?;
let last = self.col_ranges.last()?;
if col < first.start.saturating_sub(1) || col > last.end {
return None;
}
self.col_ranges
.iter()
.enumerate()
.min_by_key(|(_, r)| range_distance(r, col))
.map(|(i, _)| i)
}
pub fn hit_test(&self, col: u16, row: u16) -> Option<TableHit> {
if let Some(handle_col) = self.delete_row_handle_col {
if col == handle_col {
for (i, y_range) in self.row_ranges.iter().enumerate() {
if row >= y_range.start && row < y_range.end {
return Some(TableHit::DeleteRowHandle {
row_idx: HEADER_ROWS + i,
});
}
}
}
}
if let Some(bot_y) = self.bottom_border_row {
if row == bot_y {
for (c, x_range) in self.col_ranges.iter().enumerate() {
if let Some(glyph_x) = column_glyph_x(x_range) {
if col.abs_diff(glyph_x) <= 1 {
return Some(TableHit::DeleteColumnHandle { col_idx: c });
}
}
}
}
}
if let Some(handle_col) = self.row_handle_col {
if col == handle_col {
if let Some(row_idx) = self.data_row_at_y(row) {
return Some(TableHit::RowHandle { row_idx });
}
}
}
if let Some(top_y) = self.top_border_row {
if row == top_y {
if let Some(col_idx) = self.column_at_x(col) {
return Some(TableHit::ColumnHandle { col_idx });
}
}
}
if self.row_count > 0 && self.row_on_table(row) {
if let Some(first) = self.col_ranges.first() {
let left_border = first.start.saturating_sub(1);
if col.abs_diff(left_border) <= 1 {
return Some(TableHit::ColumnBorder { col_idx: 0 });
}
}
for (c, x_range) in self.col_ranges.iter().enumerate() {
let border_col = x_range.end;
if col.abs_diff(border_col) <= 1 {
return Some(TableHit::ColumnBorder { col_idx: c + 1 });
}
}
}
for (i, y_range) in self.row_ranges.iter().enumerate() {
if row >= y_range.start && row < y_range.end {
for (c, x_range) in self.col_ranges.iter().enumerate() {
if col >= x_range.start && col < x_range.end {
return Some(TableHit::Cell {
row_idx: HEADER_ROWS + i,
col_idx: c,
});
}
}
}
}
None
}
fn row_on_table(&self, row: u16) -> bool {
let first_y = self
.top_border_row
.or_else(|| self.row_ranges.first().map(|r| r.start.saturating_sub(2)))
.unwrap_or(0);
let last_y = self
.row_ranges
.last()
.map(|r| r.end + 1) .unwrap_or(first_y);
row >= first_y && row <= last_y
}
}
fn range_distance(range: &Range<u16>, v: u16) -> u16 {
if v < range.start {
range.start - v
} else if v >= range.end {
v - range.end + 1
} else {
0
}
}
fn column_glyph_x(x_range: &Range<u16>) -> Option<u16> {
if x_range.end <= x_range.start {
return None;
}
Some(x_range.start + (x_range.end - x_range.start) / 2)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DropIndicator {
Row {
table_byte_start: usize,
src_row_idx: usize,
hover_row_idx: usize,
},
Column {
table_byte_start: usize,
src_col_idx: usize,
hover_col_idx: usize,
},
#[allow(dead_code)]
ColumnBorder { table_byte_start: usize, x: u16 },
}
pub fn build_snapshots_cached(
state: &EditorState,
area: Rect,
show_handles: bool,
snapshots: &mut Vec<TableLayoutSnapshot>,
cache_key: &mut Option<(usize, Rect, u64, bool)>,
) {
let key = (state.scroll, area, state.parsed_version, show_handles);
if *cache_key == Some(key) {
return;
}
*snapshots = build_snapshots(state, area, show_handles);
*cache_key = Some(key);
}
pub fn build_snapshots(
state: &EditorState,
area: Rect,
show_handles: bool,
) -> Vec<TableLayoutSnapshot> {
let mut out: Vec<TableLayoutSnapshot> = Vec::new();
if area.height == 0 {
return out;
}
let source = state.buffer.contents();
let lines = &state.parsed.lines;
let total = lines.len();
let width = area.width as usize;
let (mut virtual_idx, mut first_sub_row) =
state.rendered_line_at_visual_row(state.scroll, width);
let mut vis_y: usize = 0;
let height = area.height as usize;
let mut open_table: Option<TableLayoutSnapshot> = None;
let mut open_table_block: Option<usize> = None; let mut current_data_row_y: Option<(usize, Range<u16>)> = None;
while vis_y < height && virtual_idx < total {
let Some(line) = lines.get(virtual_idx) else {
break;
};
let full_rows_used = state
.parsed
.visual_rows_for_line_at(virtual_idx, width)
.max(1);
let rows_used = full_rows_used.saturating_sub(first_sub_row).max(1);
let block_byte = state
.parsed
.source_map
.original_byte_for_rendered_line(virtual_idx);
let mut current_block: Option<usize> = None;
let mut sub_kind: Option<TableSubLineKind> = None;
if let Some(bb) = block_byte {
if let Some(range) = state.parsed.source_map.original_range_for_byte(bb) {
let end = range.end.min(source.len());
let block_text = source.get(range.start..end).unwrap_or("");
if table_edit::is_table_block(block_text) {
current_block = Some(range.start);
let own = state.parsed.source_map.rendered_lines_for_byte(range.start);
let sub_in_block = virtual_idx.saturating_sub(own.start);
let block_lines = lines.get(own.start..own.end).unwrap_or(&[]);
let kinds = classify_table_sub_lines(block_lines);
sub_kind = kinds.get(sub_in_block).copied();
}
}
}
if current_block != open_table_block {
if let Some(mut prev) = open_table.take() {
if let Some((_, range)) = current_data_row_y.take() {
prev.row_ranges.push(range);
}
out.push(prev);
}
open_table_block = None;
current_data_row_y = None;
}
if let Some(table_start) = current_block {
if open_table.is_none() {
if let Some(info) = table_edit::find_table_at(&source, table_start) {
open_table = Some(TableLayoutSnapshot {
table_byte_start: info.start,
table_byte_end: info.end,
col_count: info.col_count,
row_count: info.rows.len(),
col_ranges: Vec::new(),
row_ranges: Vec::new(),
row_handle_col: None,
top_border_row: None,
header_row: None,
delete_row_handle_col: None,
bottom_border_row: None,
});
open_table_block = Some(table_start);
}
}
if let Some(snap) = open_table.as_mut() {
if snap.col_ranges.is_empty() {
let pipes = table_layout::rendered_pipe_positions(line);
if pipes.len() == snap.col_count + 1 {
for i in 0..snap.col_count {
let start = pipes[i] as u16 + 1;
let end = pipes[i + 1] as u16;
snap.col_ranges.push(Range { start, end });
}
}
}
let y = vis_y as u16;
let y_end = y + rows_used as u16;
match sub_kind {
Some(TableSubLineKind::TopBorder) => {
if show_handles && snap.top_border_row.is_none() {
snap.top_border_row = Some(y);
}
}
Some(TableSubLineKind::Header { sub: 0 }) => {
if show_handles && snap.header_row.is_none() {
snap.header_row = Some(y);
}
}
Some(TableSubLineKind::Header { .. }) => {
}
Some(TableSubLineKind::ThickSeparator)
| Some(TableSubLineKind::ThinSeparator)
| Some(TableSubLineKind::BottomBorder) => {
if let Some((_, range)) = current_data_row_y.take() {
snap.row_ranges.push(range);
}
if matches!(sub_kind, Some(TableSubLineKind::BottomBorder))
&& show_handles
&& snap.bottom_border_row.is_none()
&& snap.col_count > 1
{
snap.bottom_border_row = Some(y);
}
}
Some(TableSubLineKind::DataRow { row, .. }) => {
match current_data_row_y.as_mut() {
Some((existing_row, range)) if *existing_row == row => {
range.end = y_end;
}
_ => {
if let Some((_, prev_range)) = current_data_row_y.take() {
snap.row_ranges.push(prev_range);
}
current_data_row_y = Some((row, y..y_end));
}
}
}
None => {}
}
if show_handles && snap.row_handle_col.is_none() && !snap.col_ranges.is_empty() {
let outer_left = snap.col_ranges[0].start.saturating_sub(1);
snap.row_handle_col = Some(outer_left.saturating_sub(1));
}
if show_handles
&& snap.delete_row_handle_col.is_none()
&& !snap.col_ranges.is_empty()
{
let outer_right = snap.col_ranges.last().unwrap().end;
snap.delete_row_handle_col = Some(outer_right);
}
}
} else {
}
vis_y += rows_used;
virtual_idx += 1;
first_sub_row = 0;
}
if let Some(mut prev) = open_table.take() {
if let Some((_, range)) = current_data_row_y.take() {
prev.row_ranges.push(range);
}
out.push(prev);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableSubLineKind {
TopBorder,
Header { sub: usize },
ThickSeparator,
DataRow { row: usize, sub: usize },
ThinSeparator,
BottomBorder,
}
pub fn classify_table_sub_lines(lines: &[Line<'_>]) -> Vec<TableSubLineKind> {
let mut out = Vec::with_capacity(lines.len());
let mut past_thick = false;
let mut current_header_sub = 0usize;
let mut current_data_row = 0usize;
let mut current_data_sub = 0usize;
let mut prev_was_data = false;
for line in lines {
let first = line
.spans
.iter()
.flat_map(|s| s.content.chars())
.next()
.unwrap_or(' ');
let kind = match first {
'┌' => TableSubLineKind::TopBorder,
'┝' => {
past_thick = true;
prev_was_data = false;
TableSubLineKind::ThickSeparator
}
'├' => {
if prev_was_data {
current_data_row += 1;
current_data_sub = 0;
}
prev_was_data = false;
TableSubLineKind::ThinSeparator
}
'└' => TableSubLineKind::BottomBorder,
'│' => {
let blank_stripe_separator =
past_thick && prev_was_data && is_blank_stripe_line(line);
if blank_stripe_separator {
current_data_row += 1;
current_data_sub = 0;
prev_was_data = false;
TableSubLineKind::ThinSeparator
} else if past_thick {
let sub = current_data_sub;
current_data_sub += 1;
prev_was_data = true;
TableSubLineKind::DataRow {
row: current_data_row,
sub,
}
} else {
let sub = current_header_sub;
current_header_sub += 1;
TableSubLineKind::Header { sub }
}
}
_ => {
TableSubLineKind::Header { sub: 0 }
}
};
out.push(kind);
}
out
}
fn is_blank_stripe_line(line: &Line<'_>) -> bool {
let mut saw_nbsp = false;
for c in line.spans.iter().flat_map(|s| s.content.chars()) {
match c {
'│' => {}
'\u{00A0}' => saw_nbsp = true,
_ => return false,
}
}
saw_nbsp
}
pub fn paint_handles(
snapshots: &[TableLayoutSnapshot],
area: Rect,
buf: &mut TuiBuf,
theme: &Theme,
cursor_table_start: Option<usize>,
) {
let move_style: Style = theme.table_handle;
let delete_style: Style = theme.table_handle_delete;
for snap in snapshots {
if let Some(start) = cursor_table_start {
if snap.table_byte_start != start {
continue;
}
} else {
return;
}
if let Some(col) = snap.row_handle_col {
if col < area.width {
for y_range in &snap.row_ranges {
let y = y_range.start;
if y < area.height {
if let Some(cell) = buf.cell_mut((area.x + col, area.y + y)) {
cell.set_char(REORDER_HANDLE_GLYPH);
cell.set_style(move_style);
}
}
}
}
}
if let Some(y) = snap.top_border_row {
if y < area.height {
for x_range in &snap.col_ranges {
let Some(x) = column_glyph_x(x_range) else {
continue;
};
if x < area.width {
if let Some(cell) = buf.cell_mut((area.x + x, area.y + y)) {
cell.set_char(REORDER_HANDLE_GLYPH);
cell.set_style(move_style);
}
}
}
}
}
if let Some(y) = snap.header_row {
if y < area.height {
for x_range in &snap.col_ranges {
let border_x = x_range.end;
if border_x < area.width {
if let Some(cell) = buf.cell_mut((area.x + border_x, area.y + y)) {
cell.set_char(COLUMN_RESIZE_GLYPH);
cell.set_style(move_style);
}
}
}
}
}
if let Some(col) = snap.delete_row_handle_col {
if col < area.width {
for y_range in &snap.row_ranges {
let y = y_range.start;
if y < area.height {
if let Some(cell) = buf.cell_mut((area.x + col, area.y + y)) {
cell.set_char(DELETE_HANDLE_GLYPH);
cell.set_style(delete_style);
}
}
}
}
}
if let Some(y) = snap.bottom_border_row {
if y < area.height {
for x_range in &snap.col_ranges {
let Some(x) = column_glyph_x(x_range) else {
continue;
};
if x < area.width {
if let Some(cell) = buf.cell_mut((area.x + x, area.y + y)) {
cell.set_char(DELETE_HANDLE_GLYPH);
cell.set_style(delete_style);
}
}
}
}
}
}
}
pub fn paint_drop_indicator(
snapshots: &[TableLayoutSnapshot],
indicator: &DropIndicator,
area: Rect,
buf: &mut TuiBuf,
theme: &Theme,
) {
let active_style: Style = theme.table_drop_indicator;
let candidate_style: Style = theme.table_drop_target;
match *indicator {
DropIndicator::Row {
table_byte_start,
src_row_idx,
hover_row_idx,
} => {
let Some(snap) = snapshots
.iter()
.find(|s| s.table_byte_start == table_byte_start)
else {
return;
};
if src_row_idx < 2 || snap.row_ranges.is_empty() {
return;
}
let active_y = active_row_drop_y(snap, src_row_idx, hover_row_idx);
let src_data_idx = src_row_idx - 2;
let Some(first) = snap.col_ranges.first() else {
return;
};
let Some(last) = snap.col_ranges.last() else {
return;
};
let x_start = first.start.saturating_sub(1);
let x_end = last.end;
let x_max = area.width;
for (i, y_range) in snap.row_ranges.iter().enumerate() {
let above = y_range.start.saturating_sub(1);
let below = y_range.end;
for &y in &[above, below] {
if y >= area.height {
continue;
}
if (i == src_data_idx && (y == above || y == below))
|| (i + 1 == src_data_idx && y == below)
|| (i == src_data_idx + 1 && y == above)
{
continue;
}
let style = if Some(y) == active_y {
active_style
} else {
candidate_style
};
paint_horizontal_drop(
buf,
area.x + x_start,
area.x + x_end,
area.x + x_max,
area.y + y,
style,
);
}
}
}
DropIndicator::Column {
table_byte_start,
src_col_idx,
hover_col_idx,
} => {
let Some(snap) = snapshots
.iter()
.find(|s| s.table_byte_start == table_byte_start)
else {
return;
};
if snap.col_ranges.is_empty() {
return;
}
let active_x = active_column_drop_x(snap, src_col_idx, hover_col_idx);
let y_top = snap
.top_border_row
.or_else(|| snap.row_ranges.first().map(|r| r.start.saturating_sub(2)))
.unwrap_or(0);
let y_bot = snap
.row_ranges
.last()
.map(|r| r.end)
.unwrap_or(area.height.saturating_sub(1));
let y_max = area.height;
let mut borders: Vec<u16> = Vec::with_capacity(snap.col_ranges.len() + 1);
if let Some(first) = snap.col_ranges.first() {
borders.push(first.start.saturating_sub(1));
}
for r in &snap.col_ranges {
borders.push(r.end);
}
for (i, &x) in borders.iter().enumerate() {
if x >= area.width {
continue;
}
if i == src_col_idx || i == src_col_idx + 1 {
continue;
}
let style = if Some(x) == active_x {
active_style
} else {
candidate_style
};
paint_vertical_drop(
buf,
area.y + y_top,
area.y + y_bot,
area.y + y_max,
area.x + x,
style,
);
}
}
DropIndicator::ColumnBorder {
table_byte_start,
x,
} => {
let Some(snap) = snapshots
.iter()
.find(|s| s.table_byte_start == table_byte_start)
else {
return;
};
if x >= area.width {
return;
}
let y_top = snap
.top_border_row
.or_else(|| snap.row_ranges.first().map(|r| r.start.saturating_sub(2)))
.unwrap_or(0);
let y_bot = snap
.row_ranges
.last()
.map(|r| r.end)
.unwrap_or(area.height.saturating_sub(1));
let y_max = area.height;
for y in y_top..=y_bot {
if y >= y_max {
break;
}
if let Some(cell) = buf.cell_mut((area.x + x, area.y + y)) {
cell.set_char(DROP_COL_GLYPH);
cell.set_style(active_style);
}
}
}
}
}
fn active_row_drop_y(
snap: &TableLayoutSnapshot,
src_row_idx: usize,
hover_row_idx: usize,
) -> Option<u16> {
if hover_row_idx < 2 || hover_row_idx == src_row_idx {
return None;
}
let data_idx = hover_row_idx - 2;
let y_range = snap.row_ranges.get(data_idx)?;
if src_row_idx > hover_row_idx {
Some(y_range.start.saturating_sub(1))
} else {
Some(y_range.end)
}
}
fn active_column_drop_x(
snap: &TableLayoutSnapshot,
src_col_idx: usize,
hover_col_idx: usize,
) -> Option<u16> {
if hover_col_idx == src_col_idx {
return None;
}
if src_col_idx > hover_col_idx {
snap.col_ranges
.get(hover_col_idx)
.map(|r| r.start.saturating_sub(1))
} else {
snap.col_ranges.get(hover_col_idx).map(|r| r.end)
}
}
fn paint_horizontal_drop(
buf: &mut TuiBuf,
x_start: u16,
x_end: u16,
x_max: u16,
y: u16,
style: Style,
) {
for x in x_start..=x_end {
if x >= x_max {
break;
}
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_char(DROP_ROW_GLYPH);
cell.set_style(style);
}
}
}
fn paint_vertical_drop(buf: &mut TuiBuf, y_top: u16, y_bot: u16, y_max: u16, x: u16, style: Style) {
for y in y_top..=y_bot {
if y >= y_max {
break;
}
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_char(DROP_COL_GLYPH);
cell.set_style(style);
}
}
}
#[cfg(test)]
#[allow(clippy::single_range_in_vec_init)]
mod tests {
use super::*;
fn snap(col_ranges: Vec<Range<u16>>, row_ranges: Vec<Range<u16>>) -> TableLayoutSnapshot {
TableLayoutSnapshot {
table_byte_start: 0,
table_byte_end: 100,
col_count: col_ranges.len(),
row_count: HEADER_ROWS + row_ranges.len(),
col_ranges,
row_ranges,
row_handle_col: None,
top_border_row: None,
header_row: None,
delete_row_handle_col: None,
bottom_border_row: None,
}
}
#[test]
fn hit_test_returns_cell_for_click_in_content() {
let s = snap(vec![1..4, 5..8], vec![3..4, 4..5]);
let hit = s.hit_test(2, 3).unwrap();
assert_eq!(
hit,
TableHit::Cell {
row_idx: 2,
col_idx: 0
}
);
}
#[test]
fn hit_test_returns_border_for_click_on_pipe() {
let s = snap(vec![1..4, 5..8], vec![3..4, 4..5]);
let hit = s.hit_test(4, 3).unwrap();
assert_eq!(hit, TableHit::ColumnBorder { col_idx: 1 });
}
#[test]
fn hit_test_border_tolerates_one_cell_miss() {
let s = snap(vec![1..4, 5..8], vec![3..4, 4..5]);
let hit = s.hit_test(5, 3).unwrap();
assert!(matches!(
hit,
TableHit::ColumnBorder { col_idx: 1 } | TableHit::Cell { col_idx: 1, .. }
));
}
#[test]
fn hit_test_row_handle_when_handles_enabled() {
let mut s = snap(vec![5..8], vec![3..4]);
s.row_handle_col = Some(2);
let hit = s.hit_test(2, 3).unwrap();
assert_eq!(hit, TableHit::RowHandle { row_idx: 2 });
}
#[test]
fn hit_test_column_handle_when_handles_enabled() {
let mut s = snap(vec![5..8, 9..12], vec![3..4]);
s.top_border_row = Some(1);
let hit = s.hit_test(6, 1).unwrap();
assert_eq!(hit, TableHit::ColumnHandle { col_idx: 0 });
}
#[test]
fn hit_test_border_in_header_row_when_resize_glyphs_on() {
let mut s = snap(vec![5..8, 9..12], vec![3..4]);
s.top_border_row = Some(1);
s.header_row = Some(2);
let hit = s.hit_test(8, 2).unwrap();
assert_eq!(hit, TableHit::ColumnBorder { col_idx: 1 });
}
#[test]
fn hit_test_row_handle_covers_the_separator_between_rows() {
let mut s = snap(vec![5..8], vec![3..4, 5..6]);
s.row_handle_col = Some(2);
assert_eq!(s.hit_test(2, 4), Some(TableHit::RowHandle { row_idx: 2 }));
assert_eq!(s.hit_test(2, 2), Some(TableHit::RowHandle { row_idx: 2 }));
assert_eq!(s.hit_test(2, 6), Some(TableHit::RowHandle { row_idx: 3 }));
assert_eq!(s.hit_test(2, 1), None);
}
#[test]
fn hit_test_column_handle_covers_the_border_vertices() {
let mut s = snap(vec![5..8, 9..12], vec![3..4]);
s.top_border_row = Some(1);
assert_eq!(
s.hit_test(4, 1),
Some(TableHit::ColumnHandle { col_idx: 0 })
);
assert_eq!(
s.hit_test(8, 1),
Some(TableHit::ColumnHandle { col_idx: 0 })
);
assert_eq!(
s.hit_test(12, 1),
Some(TableHit::ColumnHandle { col_idx: 1 })
);
assert_eq!(
s.hit_test(13, 1),
Some(TableHit::ColumnBorder { col_idx: 2 })
);
}
#[test]
fn hit_test_column_delete_is_scoped_to_the_glyph() {
let mut s = snap(vec![1..8, 9..16], vec![3..4]);
s.bottom_border_row = Some(5);
assert_eq!(
s.hit_test(4, 5),
Some(TableHit::DeleteColumnHandle { col_idx: 0 })
);
assert_eq!(
s.hit_test(5, 5),
Some(TableHit::DeleteColumnHandle { col_idx: 0 })
);
assert_eq!(
s.hit_test(8, 5),
Some(TableHit::ColumnBorder { col_idx: 1 })
);
}
#[test]
fn hit_test_returns_none_outside_any_region() {
let s = snap(vec![1..4, 5..8], vec![3..4]);
assert!(s.hit_test(100, 100).is_none());
}
#[test]
fn hit_test_returns_delete_row_handle_when_handle_set() {
let mut s = snap(vec![1..4, 5..8], vec![3..4, 4..5]);
s.delete_row_handle_col = Some(8);
let hit = s.hit_test(8, 3).unwrap();
assert_eq!(hit, TableHit::DeleteRowHandle { row_idx: 2 });
let hit = s.hit_test(8, 4).unwrap();
assert_eq!(hit, TableHit::DeleteRowHandle { row_idx: 3 });
}
#[test]
fn hit_test_cell_just_inside_right_border_still_resizes() {
let mut s = snap(vec![1..4, 5..8], vec![3..4]);
s.delete_row_handle_col = Some(8); let hit = s.hit_test(7, 3).unwrap();
assert_eq!(hit, TableHit::ColumnBorder { col_idx: 2 });
}
#[test]
fn hit_test_right_border_on_non_data_row_still_resizes() {
let mut s = snap(vec![1..4, 5..8], vec![5..6]); s.delete_row_handle_col = Some(8);
assert_eq!(
s.hit_test(8, 3),
Some(TableHit::ColumnBorder { col_idx: 2 })
);
}
#[test]
fn hit_test_falls_through_when_delete_handles_disabled() {
let s = snap(vec![1..4, 5..8], vec![3..4]);
assert_eq!(
s.hit_test(8, 3),
Some(TableHit::ColumnBorder { col_idx: 2 })
);
}
#[test]
fn hit_test_returns_delete_column_handle_on_bottom_border() {
let mut s = snap(vec![1..4, 5..8], vec![3..4]);
s.bottom_border_row = Some(5);
let hit = s.hit_test(2, 5).unwrap();
assert_eq!(hit, TableHit::DeleteColumnHandle { col_idx: 0 });
let hit = s.hit_test(6, 5).unwrap();
assert_eq!(hit, TableHit::DeleteColumnHandle { col_idx: 1 });
}
#[test]
fn hit_test_delete_row_handle_skips_header_and_alignment() {
let mut s = snap(vec![1..4, 5..8], vec![5..6]); s.delete_row_handle_col = Some(8);
assert_ne!(
s.hit_test(8, 2),
Some(TableHit::DeleteRowHandle { row_idx: 2 })
);
}
#[test]
fn build_snapshots_cached_reuses_output_when_key_matches() {
use crate::config::Theme;
use crate::document::Buffer;
use crate::editor::EditorState;
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let state = EditorState::new(Buffer::from_str(src), theme);
let area = Rect::new(0, 0, 40, 10);
let mut snapshots: Vec<TableLayoutSnapshot> = Vec::new();
let mut key: Option<(usize, Rect, u64, bool)> = None;
build_snapshots_cached(&state, area, false, &mut snapshots, &mut key);
let first_len = snapshots.len();
assert!(first_len > 0, "expected at least one snapshot");
snapshots[0].col_count = 999;
build_snapshots_cached(&state, area, false, &mut snapshots, &mut key);
assert_eq!(
snapshots[0].col_count, 999,
"snapshots must not be rebuilt when cache key matches",
);
build_snapshots_cached(&state, area, true, &mut snapshots, &mut key);
assert_ne!(
snapshots[0].col_count, 999,
"snapshots must be rebuilt when show_handles changes",
);
}
#[test]
fn classify_table_sub_lines_simple_table() {
use ratatui::text::Span;
let lines = vec![
Line::from(Span::raw("┌──┬──┐")),
Line::from(Span::raw("│ a│ b│")),
Line::from(Span::raw("┝━━┿━━┥")),
Line::from(Span::raw("│ 1│ 2│")),
Line::from(Span::raw("├──┼──┤")),
Line::from(Span::raw("│ 3│ 4│")),
Line::from(Span::raw("└──┴──┘")),
];
let kinds = classify_table_sub_lines(&lines);
assert_eq!(kinds[0], TableSubLineKind::TopBorder);
assert_eq!(kinds[1], TableSubLineKind::Header { sub: 0 });
assert_eq!(kinds[2], TableSubLineKind::ThickSeparator);
assert_eq!(kinds[3], TableSubLineKind::DataRow { row: 0, sub: 0 });
assert_eq!(kinds[4], TableSubLineKind::ThinSeparator);
assert_eq!(kinds[5], TableSubLineKind::DataRow { row: 1, sub: 0 });
assert_eq!(kinds[6], TableSubLineKind::BottomBorder);
}
#[test]
fn classify_table_sub_lines_multirow_data_row() {
use ratatui::text::Span;
let lines = vec![
Line::from(Span::raw("┌──┬──┐")),
Line::from(Span::raw("│ a│ b│")),
Line::from(Span::raw("┝━━┿━━┥")),
Line::from(Span::raw("│ 1│ x│")),
Line::from(Span::raw("│ │ y│")),
Line::from(Span::raw("├──┼──┤")),
Line::from(Span::raw("│ 3│ 4│")),
Line::from(Span::raw("└──┴──┘")),
];
let kinds = classify_table_sub_lines(&lines);
assert_eq!(kinds[3], TableSubLineKind::DataRow { row: 0, sub: 0 });
assert_eq!(kinds[4], TableSubLineKind::DataRow { row: 0, sub: 1 });
assert_eq!(kinds[5], TableSubLineKind::ThinSeparator);
assert_eq!(kinds[6], TableSubLineKind::DataRow { row: 1, sub: 0 });
}
#[test]
fn classify_table_sub_lines_blank_stripe_separator() {
use ratatui::text::Span;
let lines = vec![
Line::from(Span::raw("┌──┬──┐")),
Line::from(Span::raw("│ a│ b│")),
Line::from(Span::raw("┝━━┿━━┥")),
Line::from(Span::raw("│ 1│ 2│")),
Line::from(Span::raw(
"│\u{00A0}\u{00A0}\u{00A0}│\u{00A0}\u{00A0}\u{00A0}│",
)),
Line::from(Span::raw("│ 3│ 4│")),
Line::from(Span::raw("└──┴──┘")),
];
let kinds = classify_table_sub_lines(&lines);
assert_eq!(kinds[3], TableSubLineKind::DataRow { row: 0, sub: 0 });
assert_eq!(kinds[4], TableSubLineKind::ThinSeparator);
assert_eq!(kinds[5], TableSubLineKind::DataRow { row: 1, sub: 0 });
}
#[test]
fn classify_table_sub_lines_ascii_space_padded_continuation_stays_data() {
use ratatui::text::Span;
let lines = vec![
Line::from(Span::raw("┌──┬──┐")),
Line::from(Span::raw("│ a│ b│")),
Line::from(Span::raw("┝━━┿━━┥")),
Line::from(Span::raw("│hi│ y│")),
Line::from(Span::raw("│ │ │")), Line::from(Span::raw("└──┴──┘")),
];
let kinds = classify_table_sub_lines(&lines);
assert_eq!(kinds[3], TableSubLineKind::DataRow { row: 0, sub: 0 });
assert_eq!(kinds[4], TableSubLineKind::DataRow { row: 0, sub: 1 });
}
}