use std::ops::Range;
use crate::document::{next_grapheme_offset, prev_grapheme_offset, EditDelta};
use crate::editor::edit_ops::cursor_byte;
use crate::editor::table_edit::{self, RowKind, TableInfo, TableRow};
use crate::editor::table_edit_ops;
use crate::editor::vim_ops::motion::{resolve_motion, resolve_motion_range, Motion, OpRange};
use crate::editor::vim_ops::operator::{execute_operator, OpResult, Operator};
use crate::editor::EditorState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellScope {
pub start: usize,
pub end: usize,
}
impl CellScope {
fn contains(&self, offset: usize) -> bool {
offset >= self.start && offset <= self.end
}
fn as_range(&self) -> Range<usize> {
self.start..self.end
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CellLimit {
Append,
LastChar,
}
fn cell_max_cursor(state: &EditorState, scope: CellScope, limit: CellLimit) -> usize {
match limit {
CellLimit::Append => scope.end,
CellLimit::LastChar => prev_grapheme_offset(&state.buffer, scope.end).max(scope.start),
}
}
pub fn cell_scope(state: &EditorState) -> Option<CellScope> {
cell_scope_at(state, state.cursor.offset)
}
fn cell_scope_at(state: &EditorState, offset: usize) -> Option<CellScope> {
let byte = state.buffer.rope().char_to_byte(offset);
let info = table_edit_ops::table_at(state, byte)?;
let (row, col) = table_edit::cursor_cell(&info, byte)?;
if info.rows.get(row)?.kind == RowKind::Alignment {
return None;
}
let start_byte = table_edit::cell_cursor_offset(&info, row, col)?;
let end_byte = table_edit::cell_end_cursor_offset(&info, row, col)?;
let rope = state.buffer.rope();
let start = rope.byte_to_char(start_byte);
let end = rope.byte_to_char(end_byte).max(start);
Some(CellScope { start, end })
}
pub fn cursor_row_kind(state: &EditorState) -> Option<RowKind> {
let info = table_edit_ops::current_table(state)?;
let byte = cursor_byte(state);
let (row, _) = table_edit::cursor_cell(&info, byte)?;
info.rows.get(row).map(|r| r.kind)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableBreak {
ProtectedRow,
CrossesCells,
}
impl TableBreak {
pub fn message(self) -> &'static str {
match self {
TableBreak::ProtectedRow => "Can't remove a table's header or alignment row",
TableBreak::CrossesCells => "Can't edit across table cells",
}
}
}
pub fn range_breaks_a_table(state: &EditorState, start: usize, end: usize) -> Option<TableBreak> {
let rope = state.buffer.rope();
let len = rope.len_bytes();
let start = start.min(len);
let end = end.min(len).max(start);
let mut probe = start;
loop {
match table_edit_ops::table_at(state, probe) {
Some(info) => {
if let Some(reason) = table_break(&info, start, end) {
return Some(reason);
}
if info.end <= probe {
break; }
probe = info.end;
}
None => {
let line = rope.byte_to_line(probe);
if line + 1 >= rope.len_lines() {
break;
}
probe = rope.line_to_byte(line + 1);
}
}
if probe >= end {
break;
}
}
None
}
pub fn op_range_breaks_a_table(state: &EditorState, range: &OpRange) -> Option<TableBreak> {
let rope = state.buffer.rope();
let (start_char, end_char) = match range {
OpRange::Chars(r) => (r.start, r.end),
OpRange::Lines { first, last } => {
let line_count = state.buffer.line_count();
let start = state.buffer.line_to_char((*first).min(line_count));
let end = if last + 1 < line_count {
state.buffer.line_to_char(last + 1)
} else {
state.buffer.len_chars()
};
(start, end)
}
};
let len_chars = rope.len_chars();
let start = rope.char_to_byte(start_char.min(len_chars));
let end = rope.char_to_byte(end_char.min(len_chars));
range_breaks_a_table(state, start, end)
}
pub fn lines_touch_a_table(state: &EditorState, first: usize, last: usize) -> bool {
let line_count = state.buffer.line_count();
if first >= line_count {
return false;
}
let last = last.min(line_count.saturating_sub(1));
let rope = state.buffer.rope();
let mut line = first;
while line <= last {
let byte = rope.line_to_byte(line);
if table_edit_ops::table_at(state, byte).is_some() {
return true;
}
line += 1;
}
false
}
fn table_break(info: &TableInfo, start: usize, end: usize) -> Option<TableBreak> {
if start <= info.start && end >= info.end {
return None;
}
let touched: Vec<&TableRow> = info
.rows
.iter()
.filter(|r| r.start < end.max(start + 1) && r.end > start)
.collect();
if touched.is_empty() {
return None;
}
if touched
.iter()
.all(|r| r.kind == RowKind::Data && r.start >= start && r.end <= end)
{
return None;
}
if let [row] = touched[..] {
let confined = if row.kind == RowKind::Alignment {
start >= row.start && end <= row.start + row.raw.len()
} else {
row.cells
.iter()
.any(|c| start >= row.start + c.content_start && end <= row.start + c.content_end)
};
if confined {
return None;
}
}
if touched.iter().all(|r| r.start >= start && r.end <= end) {
Some(TableBreak::ProtectedRow)
} else {
Some(TableBreak::CrossesCells)
}
}
fn motion_is_cell_scoped(motion: Motion) -> bool {
match motion {
Motion::Left
| Motion::Right
| Motion::WordForward
| Motion::WordEnd
| Motion::WordBackward
| Motion::CurrentWordEnd
| Motion::CurrentBigWordEnd
| Motion::BigWordForward
| Motion::BigWordEnd
| Motion::BigWordBackward
| Motion::LineStart
| Motion::LineFirstNonBlank
| Motion::LineEnd
| Motion::FindChar(..) => true,
Motion::DocStart
| Motion::DocEnd
| Motion::GoToLine(_)
| Motion::ParagraphForward
| Motion::ParagraphBackward
| Motion::MatchingPair => false,
}
}
pub fn scope_offset(state: &EditorState, motion: Motion, target: usize, limit: CellLimit) -> usize {
if !motion_is_cell_scoped(motion) {
return target;
}
let Some(scope) = cell_scope(state) else {
return target;
};
let max = cell_max_cursor(state, scope, limit);
if target >= scope.start && target <= max {
return target;
}
if matches!(motion, Motion::FindChar(..)) {
return state.cursor.offset;
}
target.clamp(scope.start, max)
}
pub fn resolve_scoped_motion(
state: &EditorState,
motion: Motion,
count: u32,
limit: CellLimit,
) -> usize {
let target = resolve_motion(motion, count, state.cursor.offset, &state.buffer);
scope_offset(state, motion, target, limit)
}
pub fn visual_cell_step(state: &mut EditorState, forward: bool) -> bool {
let Some(scope) = cell_scope(state) else {
return false;
};
let cursor = state.cursor.offset;
if !scope.contains(cursor) {
return false;
}
let target = if forward {
next_grapheme_offset(&state.buffer, cursor)
} else {
prev_grapheme_offset(&state.buffer, cursor)
};
let max = cell_max_cursor(state, scope, CellLimit::LastChar).max(cursor);
state.cursor.offset = target.clamp(scope.start, max);
state.cursor.preferred_col = state.cursor.cell_col(&state.buffer);
true
}
pub fn visual_endpoint_in_cell(state: &EditorState, offset: usize) -> Option<usize> {
let scope = cell_scope_at(state, offset)?;
if !scope.contains(offset) {
return None;
}
Some(offset.min(cell_max_cursor(state, scope, CellLimit::LastChar)))
}
pub fn resolve_scoped_op_range(state: &EditorState, motion: Motion, count: u32) -> OpRange {
let cursor = state.cursor.offset;
let range = resolve_motion_range(motion, count, cursor, &state.buffer);
let OpRange::Chars(chars) = range else {
return range;
};
if !motion_is_cell_scoped(motion) {
return OpRange::Chars(chars);
}
let Some(scope) = cell_scope(state) else {
return OpRange::Chars(chars);
};
if matches!(motion, Motion::FindChar(..)) {
let dest = resolve_motion(motion, count, cursor, &state.buffer);
if !scope.contains(dest) {
return OpRange::Chars(cursor..cursor);
}
}
let start = chars.start.clamp(scope.start, scope.end);
let end = chars.end.clamp(scope.start, scope.end).max(start);
OpRange::Chars(start..end)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TableOpOutcome {
Applied(OpResult),
Refused(TableBreak),
NotATable,
}
pub fn open_table_row(
state: &mut EditorState,
below: bool,
viewport_height: usize,
viewport_width: usize,
) -> bool {
if table_edit_ops::current_table(state).is_none() {
return false;
}
table_edit_ops::table_insert_row(state, below, viewport_height, viewport_width);
true
}
pub fn delete_table_row(
state: &mut EditorState,
viewport_height: usize,
viewport_width: usize,
) -> TableOpOutcome {
let Some(info) = table_edit_ops::current_table(state) else {
return TableOpOutcome::NotATable;
};
let byte = cursor_byte(state);
let Some((row_idx, _)) = table_edit::cursor_cell(&info, byte) else {
return TableOpOutcome::NotATable;
};
let Some(row) = info.rows.get(row_idx) else {
return TableOpOutcome::NotATable;
};
if row.kind != RowKind::Data {
return TableOpOutcome::Refused(TableBreak::ProtectedRow);
}
let register_text = format!("{}\n", row.raw);
table_edit_ops::table_delete_row(state, viewport_height, viewport_width);
TableOpOutcome::Applied(OpResult {
register_text,
linewise: true,
enter_insert: false,
})
}
pub fn clear_table_cell(state: &mut EditorState) -> TableOpOutcome {
if table_edit_ops::current_table(state).is_none() {
return TableOpOutcome::NotATable;
}
let Some(scope) = cell_scope(state) else {
return TableOpOutcome::Refused(TableBreak::ProtectedRow);
};
TableOpOutcome::Applied(execute_operator(
state,
Operator::Change,
OpRange::Chars(scope.as_range()),
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TablePaste {
RowsAt(usize),
Refused,
NotATable,
}
pub fn table_paste_plan(
state: &EditorState,
text: &str,
linewise: bool,
after: bool,
) -> TablePaste {
let Some(info) = table_edit_ops::current_table(state) else {
return TablePaste::NotATable;
};
if !linewise {
return if text.contains('|') || text.contains('\n') {
TablePaste::Refused
} else {
TablePaste::NotATable
};
}
if !text
.lines()
.all(|l| l.trim().is_empty() || table_edit::is_table_line(l))
{
return TablePaste::Refused;
}
let byte = cursor_byte(state);
let Some((row_idx, _)) = table_edit::cursor_cell(&info, byte) else {
return TablePaste::NotATable;
};
let target = if after { row_idx + 1 } else { row_idx };
let target = target.clamp(2, info.rows.len());
let byte_at = if target < info.rows.len() {
info.rows[target].start
} else {
info.end
};
TablePaste::RowsAt(state.buffer.rope().byte_to_char(byte_at))
}
pub fn insert_table_rows(state: &mut EditorState, at: usize, text: &str) {
let needs_separator = at > 0 && state.buffer.rope().char(at - 1) != '\n';
let payload = if needs_separator {
format!("\n{}", text.strip_suffix('\n').unwrap_or(text))
} else {
text.to_owned()
};
let landing = if needs_separator { at + 1 } else { at };
state.apply_delta(EditDelta {
offset: at,
removed: String::new(),
inserted: payload,
});
state.place_cursor(landing.min(state.buffer.len_chars()));
if let Some(scope) = cell_scope(state) {
state.place_cursor(scope.start);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Theme;
use crate::document::Buffer;
use crate::editor::vim_ops::motion::FindKind;
use crate::editor::Mode;
const TABLE: &str = "| alpha | bravo |\n|---|---|\n| one | two |\n";
fn state_at(offset: usize) -> EditorState {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let mut st = EditorState::new(Buffer::from_str(TABLE), theme);
st.mode = Mode::Rendered;
st.cursor.offset = offset;
st.update_cursor_block();
st
}
fn at(needle: &str) -> usize {
TABLE.find(needle).expect("needle present in fixture")
}
#[test]
fn cell_scope_spans_only_the_cursors_cell() {
let st = state_at(at("alpha"));
let scope = cell_scope(&st).expect("cursor is in a header cell");
assert_eq!(scope.start, at("alpha"));
assert_eq!(scope.end, at("alpha") + "alpha".len());
}
#[test]
fn cell_scope_is_none_on_the_alignment_row() {
let st = state_at(at("|---|") + 2);
assert!(cell_scope(&st).is_none());
assert_eq!(cursor_row_kind(&st), Some(RowKind::Alignment));
}
#[test]
fn cell_scope_is_none_outside_a_table() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let mut st = EditorState::new(Buffer::from_str("just a paragraph\n"), theme);
st.mode = Mode::Rendered;
st.update_cursor_block();
assert!(cell_scope(&st).is_none());
assert!(cursor_row_kind(&st).is_none());
}
#[test]
fn raw_mode_has_no_cell_scope() {
let mut st = state_at(at("alpha"));
st.mode = Mode::Raw;
assert!(cell_scope(&st).is_none());
assert!(cursor_row_kind(&st).is_none());
let scoped = resolve_scoped_motion(&st, Motion::LineEnd, 1, CellLimit::Append);
let bare = resolve_motion(Motion::LineEnd, 1, st.cursor.offset, &st.buffer);
assert_eq!(scoped, bare);
}
#[test]
fn cell_scoped_motions_match_the_spec() {
for m in [
Motion::Left,
Motion::Right,
Motion::WordForward,
Motion::WordEnd,
Motion::WordBackward,
Motion::CurrentWordEnd,
Motion::CurrentBigWordEnd,
Motion::BigWordForward,
Motion::BigWordEnd,
Motion::BigWordBackward,
Motion::LineStart,
Motion::LineFirstNonBlank,
Motion::LineEnd,
Motion::FindChar('x', FindKind::Forward),
] {
assert!(motion_is_cell_scoped(m), "{m:?} must be cell-scoped");
}
for m in [
Motion::DocStart,
Motion::DocEnd,
Motion::GoToLine(3),
Motion::ParagraphForward,
Motion::ParagraphBackward,
Motion::MatchingPair,
] {
assert!(!motion_is_cell_scoped(m), "{m:?} must escape the cell");
}
}
#[test]
fn line_end_clamps_to_the_cell_not_the_row() {
let st = state_at(at("alpha"));
let target = resolve_scoped_motion(&st, Motion::LineEnd, 1, CellLimit::Append);
assert_eq!(target, at("alpha") + "alpha".len());
}
#[test]
fn word_forward_stops_at_the_cell_edge() {
let st = state_at(at("alpha"));
let bare = resolve_motion(Motion::WordForward, 1, st.cursor.offset, &st.buffer);
assert!(bare > at("alpha") + "alpha".len());
let scoped = resolve_scoped_motion(&st, Motion::WordForward, 1, CellLimit::Append);
assert_eq!(scoped, at("alpha") + "alpha".len());
}
#[test]
fn find_outside_the_cell_does_not_move_the_cursor() {
let st = state_at(at("alpha"));
let motion = Motion::FindChar('b', FindKind::Forward);
assert_eq!(
resolve_scoped_motion(&st, motion, 1, CellLimit::Append),
st.cursor.offset
);
assert_eq!(
resolve_scoped_op_range(&st, motion, 1),
OpRange::Chars(st.cursor.offset..st.cursor.offset)
);
}
#[test]
fn find_inside_the_cell_still_resolves() {
let st = state_at(at("alpha"));
let motion = Motion::FindChar('h', FindKind::Forward);
assert_eq!(
resolve_scoped_motion(&st, motion, 1, CellLimit::Append),
at("alpha") + 3
);
}
#[test]
fn line_end_op_range_stops_at_the_cell_edge() {
let st = state_at(at("alpha"));
assert_eq!(
resolve_scoped_op_range(&st, Motion::LineEnd, 1),
OpRange::Chars(at("alpha")..at("alpha") + "alpha".len())
);
}
#[test]
fn right_op_range_never_reaches_the_delimiter() {
let last = at("alpha") + "alpha".len() - 1;
let st = state_at(last);
assert_eq!(
resolve_scoped_op_range(&st, Motion::Right, 1),
OpRange::Chars(last..last + 1)
);
let past = at("alpha") + "alpha".len();
let st = state_at(past);
assert_eq!(
resolve_scoped_op_range(&st, Motion::Right, 1),
OpRange::Chars(past..past)
);
}
#[test]
fn the_visual_limit_stops_one_grapheme_short_of_the_append_slot() {
let st = state_at(at("alpha"));
let last = at("alpha") + "alpha".len();
assert_eq!(
resolve_scoped_motion(&st, Motion::LineEnd, 1, CellLimit::Append),
last
);
assert_eq!(
resolve_scoped_motion(&st, Motion::LineEnd, 1, CellLimit::LastChar),
last - 1
);
}
#[test]
fn the_visual_limit_steps_back_a_whole_grapheme() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let src = "| ae\u{301} | b |\n|---|---|\n| one | two |\n";
let mut st = EditorState::new(Buffer::from_str(src), theme);
st.mode = Mode::Rendered;
st.cursor.offset = 2;
st.update_cursor_block();
let scope = cell_scope(&st).expect("cursor is in a cell");
assert_eq!(scope.end, 5, "content is `ae\u{301}`");
assert_eq!(
resolve_scoped_motion(&st, Motion::LineEnd, 1, CellLimit::LastChar),
3,
"`e\u{301}` is one grapheme, so the limit is the `e`, not the accent"
);
}
#[test]
fn visual_cell_step_holds_the_cursor_inside_the_cell() {
let mut st = state_at(at("alpha"));
for _ in 0..9 {
assert!(visual_cell_step(&mut st, true));
}
assert_eq!(st.cursor.offset, at("alpha") + "alpha".len() - 1);
for _ in 0..9 {
assert!(visual_cell_step(&mut st, false));
}
assert_eq!(st.cursor.offset, at("alpha"));
}
#[test]
fn visual_cell_step_forward_never_moves_backwards() {
let mut st = state_at(at("alpha") + "alpha".len());
assert!(visual_cell_step(&mut st, true));
assert_eq!(st.cursor.offset, at("alpha") + "alpha".len());
}
#[test]
fn visual_cell_step_declines_outside_a_cell() {
let mut st = state_at(at("|---|") + 2); assert!(!visual_cell_step(&mut st, true));
let mut st = state_at(at("alpha"));
st.mode = Mode::Raw;
assert!(!visual_cell_step(&mut st, true));
}
#[test]
fn visual_endpoint_pulls_back_from_the_append_slot_only() {
let st = state_at(at("alpha") + "alpha".len());
assert_eq!(
visual_endpoint_in_cell(&st, st.cursor.offset),
Some(at("alpha") + "alpha".len() - 1)
);
let st = state_at(at("alpha") + 2);
assert_eq!(
visual_endpoint_in_cell(&st, st.cursor.offset),
Some(at("alpha") + 2)
);
let st = state_at(at("|---|") + 2);
assert_eq!(visual_endpoint_in_cell(&st, st.cursor.offset), None);
}
#[test]
fn visual_endpoint_answers_for_a_cell_the_cursor_is_not_in() {
let st = state_at(at("alpha"));
let bravo_append = at("bravo") + "bravo".len();
assert_eq!(
visual_endpoint_in_cell(&st, bravo_append),
Some(bravo_append - 1)
);
let two_append = at("two") + "two".len();
assert_eq!(
visual_endpoint_in_cell(&st, two_append),
Some(two_append - 1)
);
}
#[test]
fn the_operator_range_keeps_the_append_bound() {
let st = state_at(at("alpha"));
assert_eq!(
resolve_scoped_op_range(&st, Motion::LineEnd, 1),
OpRange::Chars(at("alpha")..at("alpha") + "alpha".len())
);
}
#[test]
fn document_motions_are_not_clamped() {
let st = state_at(at("one"));
assert_eq!(
resolve_scoped_motion(&st, Motion::DocStart, 1, CellLimit::Append),
resolve_motion(Motion::DocStart, 1, st.cursor.offset, &st.buffer)
);
}
#[test]
fn delete_table_row_refuses_header_and_alignment() {
let mut st = state_at(at("alpha"));
assert_eq!(
delete_table_row(&mut st, 40, 80),
TableOpOutcome::Refused(TableBreak::ProtectedRow)
);
assert_eq!(st.buffer.contents(), TABLE);
let mut st = state_at(at("|---|") + 2);
assert_eq!(
delete_table_row(&mut st, 40, 80),
TableOpOutcome::Refused(TableBreak::ProtectedRow)
);
assert_eq!(st.buffer.contents(), TABLE);
}
#[test]
fn delete_table_row_removes_a_data_row_and_fills_the_register() {
let mut st = state_at(at("one"));
let outcome = delete_table_row(&mut st, 40, 80);
let TableOpOutcome::Applied(res) = outcome else {
panic!("expected the data row to be deleted, got {outcome:?}");
};
assert_eq!(res.register_text, "| one | two |\n");
assert!(res.linewise);
assert!(!res.enter_insert);
assert!(!st.buffer.contents().contains("one"));
assert!(st.buffer.contents().contains("| alpha | bravo |"));
}
#[test]
fn delete_table_row_outside_a_table_declines() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let mut st = EditorState::new(Buffer::from_str("paragraph\n"), theme);
st.mode = Mode::Rendered;
st.update_cursor_block();
assert_eq!(delete_table_row(&mut st, 40, 80), TableOpOutcome::NotATable);
}
#[test]
fn clear_table_cell_empties_only_that_cell() {
let mut st = state_at(at("alpha"));
let TableOpOutcome::Applied(res) = clear_table_cell(&mut st) else {
panic!("cursor is in a cell");
};
assert_eq!(res.register_text, "alpha");
assert!(res.enter_insert);
assert!(!res.linewise);
assert!(st.buffer.contents().starts_with("| | bravo |\n"));
assert!(st.buffer.contents().contains("| one | two |"));
}
#[test]
fn clear_table_cell_refuses_on_the_alignment_row() {
let mut st = state_at(at("|---|") + 2);
assert_eq!(
clear_table_cell(&mut st),
TableOpOutcome::Refused(TableBreak::ProtectedRow)
);
assert_eq!(st.buffer.contents(), TABLE);
}
#[test]
fn clear_table_cell_outside_a_table_declines() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let mut st = EditorState::new(Buffer::from_str("paragraph\n"), theme);
st.mode = Mode::Rendered;
st.update_cursor_block();
assert_eq!(clear_table_cell(&mut st), TableOpOutcome::NotATable);
}
#[test]
fn open_table_row_inserts_a_structural_row() {
let mut st = state_at(at("one"));
assert!(open_table_row(&mut st, true, 40, 80));
let contents = st.buffer.contents();
assert_eq!(contents.lines().count(), 4);
assert!(contents.lines().nth(3).is_some_and(|l| l.contains('|')));
}
#[test]
fn open_table_row_declines_outside_a_table() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let mut st = EditorState::new(Buffer::from_str("paragraph\n"), theme);
st.mode = Mode::Rendered;
st.update_cursor_block();
assert!(!open_table_row(&mut st, true, 40, 80));
}
fn line_span(st: &EditorState, first: usize, last: usize) -> (usize, usize) {
let rope = st.buffer.rope();
let start = rope.line_to_byte(first);
let end = if last + 1 < rope.len_lines() {
rope.line_to_byte(last + 1)
} else {
rope.len_bytes()
};
(start, end)
}
#[test]
fn deleting_a_whole_data_row_is_allowed() {
let st = state_at(at("one"));
let (s, e) = line_span(&st, 2, 2);
assert_eq!(range_breaks_a_table(&st, s, e), None);
}
#[test]
fn deleting_the_header_or_alignment_row_breaks_the_table() {
let st = state_at(at("one"));
for line in [0, 1] {
let (s, e) = line_span(&st, line, line);
assert_eq!(
range_breaks_a_table(&st, s, e),
Some(TableBreak::ProtectedRow),
"line {line} carries the table's shape"
);
}
}
#[test]
fn a_counted_span_over_protected_rows_breaks_the_table() {
let st = state_at(at("alpha"));
let (s, e) = line_span(&st, 0, 1);
assert_eq!(
range_breaks_a_table(&st, s, e),
Some(TableBreak::ProtectedRow)
);
}
#[test]
fn deleting_the_whole_table_is_allowed() {
let st = state_at(at("alpha"));
let (s, e) = line_span(&st, 0, 2);
assert_eq!(range_breaks_a_table(&st, s, e), None);
}
#[test]
fn a_span_reaching_in_from_outside_the_table_still_breaks_it() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let src = format!("para\n{TABLE}");
let mut st = EditorState::new(Buffer::from_str(&src), theme);
st.mode = Mode::Rendered;
st.update_cursor_block();
let (s, e) = line_span(&st, 0, 1);
assert_eq!(
range_breaks_a_table(&st, s, e),
Some(TableBreak::ProtectedRow)
);
}
#[test]
fn an_edit_inside_one_cell_is_allowed() {
let st = state_at(at("alpha"));
let rope = st.buffer.rope();
let s = rope.char_to_byte(at("alpha"));
let e = rope.char_to_byte(at("alpha") + "alpha".len());
assert_eq!(range_breaks_a_table(&st, s, e), None);
}
#[test]
fn an_edit_across_two_cells_breaks_the_table() {
let st = state_at(at("alpha"));
let rope = st.buffer.rope();
let s = rope.char_to_byte(at("alpha"));
let e = rope.char_to_byte(at("bravo") + 2);
assert_eq!(
range_breaks_a_table(&st, s, e),
Some(TableBreak::CrossesCells)
);
}
#[test]
fn the_alignment_rows_own_text_stays_editable() {
let st = state_at(at("|---|") + 2);
let rope = st.buffer.rope();
let s = rope.char_to_byte(at("|---|") + 1);
let e = rope.char_to_byte(at("|---|") + 4);
assert_eq!(range_breaks_a_table(&st, s, e), None);
let (s, e) = line_span(&st, 1, 1);
assert_eq!(
range_breaks_a_table(&st, s, e),
Some(TableBreak::ProtectedRow)
);
}
#[test]
fn a_range_in_ordinary_prose_never_breaks_anything() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let mut st = EditorState::new(Buffer::from_str("one\ntwo\nthree\n"), theme);
st.mode = Mode::Rendered;
st.update_cursor_block();
assert_eq!(range_breaks_a_table(&st, 0, 13), None);
}
#[test]
fn raw_mode_never_reports_a_break() {
let mut st = state_at(at("alpha"));
st.mode = Mode::Raw;
let (s, e) = line_span(&st, 0, 1);
assert_eq!(range_breaks_a_table(&st, s, e), None);
}
#[test]
fn op_range_lines_are_measured_like_the_operator_measures_them() {
let st = state_at(at("one"));
assert_eq!(
op_range_breaks_a_table(&st, &OpRange::Lines { first: 0, last: 0 }),
Some(TableBreak::ProtectedRow)
);
assert_eq!(
op_range_breaks_a_table(&st, &OpRange::Lines { first: 2, last: 2 }),
None
);
}
#[test]
fn lines_touch_a_table_spots_any_overlap() {
let st = state_at(at("one"));
assert!(lines_touch_a_table(&st, 0, 0));
assert!(lines_touch_a_table(&st, 2, 2));
assert!(lines_touch_a_table(&st, 0, 2));
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let mut prose = EditorState::new(Buffer::from_str("one\ntwo\n"), theme);
prose.mode = Mode::Rendered;
prose.update_cursor_block();
assert!(!lines_touch_a_table(&prose, 0, 1));
}
#[test]
fn a_row_pasted_on_the_header_lands_below_the_alignment_row() {
let st = state_at(at("alpha"));
let plan = table_paste_plan(
&st,
"| x | y |\n",
true,
true,
);
let TablePaste::RowsAt(offset) = plan else {
panic!("expected a row insertion, got {plan:?}");
};
assert_eq!(
offset,
at("| one"),
"the row must land on the first data row's boundary, not above the alignment row"
);
}
#[test]
fn pasting_prose_into_a_table_is_refused() {
let st = state_at(at("one"));
assert_eq!(
table_paste_plan(&st, "just a paragraph\n", true, true),
TablePaste::Refused
);
assert_eq!(
table_paste_plan(&st, "a | b", false, true),
TablePaste::Refused
);
assert_eq!(
table_paste_plan(&st, "text", false, true),
TablePaste::NotATable
);
}
#[test]
fn paste_plans_nothing_outside_a_table() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let mut st = EditorState::new(Buffer::from_str("paragraph\n"), theme);
st.mode = Mode::Rendered;
st.update_cursor_block();
assert_eq!(
table_paste_plan(&st, "| x | y |\n", true, true),
TablePaste::NotATable
);
}
#[test]
fn insert_table_rows_lands_on_the_new_rows_first_cell() {
let mut st = state_at(at("one"));
let plan = table_paste_plan(&st, "| x | y |\n", true, true);
let TablePaste::RowsAt(offset) = plan else {
panic!("expected a row insertion, got {plan:?}");
};
insert_table_rows(&mut st, offset, "| x | y |\n");
assert_eq!(
st.buffer.contents(),
"| alpha | bravo |\n|---|---|\n| one | two |\n| x | y |\n"
);
assert_eq!(
st.buffer
.slice_to_string(st.cursor.offset, st.cursor.offset + 1),
"x",
"the cursor lands on the pasted row's first cell"
);
}
#[test]
fn insert_table_rows_adds_its_own_separator() {
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let mut st = EditorState::new(Buffer::from_str("| a | b |\n|---|---|\n| 1 | 2 |"), theme);
st.mode = Mode::Rendered;
st.cursor.offset = 24; st.update_cursor_block();
let plan = table_paste_plan(&st, "| x | y |\n", true, true);
let TablePaste::RowsAt(offset) = plan else {
panic!("expected a row insertion, got {plan:?}");
};
insert_table_rows(&mut st, offset, "| x | y |\n");
assert_eq!(
st.buffer.contents(),
"| a | b |\n|---|---|\n| 1 | 2 |\n| x | y |"
);
}
}