use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Next,
Previous,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TargetPosition {
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TableNavOutcome {
pub in_table: bool,
pub position: Option<TargetPosition>,
}
impl TableNavOutcome {
fn fallthrough() -> Self {
Self {
in_table: false,
position: None,
}
}
fn no_move() -> Self {
Self {
in_table: true,
position: None,
}
}
fn moved(line: usize, column: usize) -> Self {
Self {
in_table: true,
position: Some(TargetPosition { line, column }),
}
}
}
pub fn navigate_table_cell(
source: &str,
line: usize,
column: usize,
direction: Direction,
) -> TableNavOutcome {
let Some(current) = nth_line(source, line) else {
return TableNavOutcome::fallthrough();
};
if !is_pipe_row(current) {
return TableNavOutcome::fallthrough();
}
let pipes = pipe_positions(current);
if pipes.len() < 2 {
return TableNavOutcome::no_move();
}
match direction {
Direction::Next => navigate_next(source, line, column, current, &pipes),
Direction::Previous => navigate_previous(source, line, column, current, &pipes),
}
}
fn navigate_next(
source: &str,
line: usize,
column: usize,
current: &str,
pipes: &[usize],
) -> TableNavOutcome {
if let Some((idx, &next_pipe)) = pipes.iter().enumerate().find(|&(_, &p)| p > column) {
if idx < pipes.len() - 1 {
let target = (next_pipe + 2).min(current.len());
return TableNavOutcome::moved(line, target);
}
}
let next_line_nr = line + 1;
if let Some(next_text) = nth_line(source, next_line_nr) {
if is_pipe_row(next_text) {
if let Some(first_pipe) = next_text.find('|') {
let target = (first_pipe + 2).min(next_text.len());
return TableNavOutcome::moved(next_line_nr, target);
}
}
}
TableNavOutcome::no_move()
}
fn navigate_previous(
source: &str,
line: usize,
column: usize,
current: &str,
pipes: &[usize],
) -> TableNavOutcome {
let before_cursor = pipes.iter().copied().take_while(|&p| p < column).count();
if before_cursor >= 2 {
let target_pipe = pipes[before_cursor - 2];
let target = (target_pipe + 2).min(current.len());
return TableNavOutcome::moved(line, target);
}
if line == 0 {
return TableNavOutcome::no_move();
}
let prev_line_nr = line - 1;
if let Some(prev_text) = nth_line(source, prev_line_nr) {
if is_pipe_row(prev_text) {
let prev_pipes = pipe_positions(prev_text);
if prev_pipes.len() >= 2 {
let target_pipe = prev_pipes[prev_pipes.len() - 2];
let target = (target_pipe + 2).min(prev_text.len());
return TableNavOutcome::moved(prev_line_nr, target);
}
}
}
TableNavOutcome::no_move()
}
fn nth_line(source: &str, n: usize) -> Option<&str> {
source.split('\n').nth(n)
}
fn is_pipe_row(line: &str) -> bool {
line.trim_start().starts_with('|')
}
fn pipe_positions(line: &str) -> Vec<usize> {
line.match_indices('|').map(|(i, _)| i).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn next(source: &str, line: usize, col: usize) -> TableNavOutcome {
navigate_table_cell(source, line, col, Direction::Next)
}
fn prev(source: &str, line: usize, col: usize) -> TableNavOutcome {
navigate_table_cell(source, line, col, Direction::Previous)
}
#[test]
fn falls_through_when_not_on_pipe_row() {
let source = "A paragraph here.\n";
let outcome = next(source, 0, 3);
assert_eq!(outcome, TableNavOutcome::fallthrough());
}
#[test]
fn falls_through_beyond_last_line() {
let source = "| a | b |\n";
let outcome = next(source, 5, 0);
assert_eq!(outcome, TableNavOutcome::fallthrough());
}
#[test]
fn moves_to_next_cell_mid_row() {
let source = " | Name | Score |\n";
let outcome = next(source, 0, 7);
assert_eq!(outcome, TableNavOutcome::moved(0, 13));
}
#[test]
fn next_from_last_cell_wraps_to_next_row() {
let source = " | A | B |\n | C | D |\n";
let outcome = next(source, 0, 11);
assert_eq!(outcome, TableNavOutcome::moved(1, 6));
}
#[test]
fn next_from_last_row_last_cell_is_no_move() {
let source = " | A | B |\n";
let outcome = next(source, 0, 11);
assert_eq!(outcome, TableNavOutcome::no_move());
}
#[test]
fn next_with_only_one_pipe_is_no_move() {
let source = "| only\n";
let outcome = next(source, 0, 2);
assert_eq!(outcome, TableNavOutcome::no_move());
}
#[test]
fn prev_moves_to_previous_cell_mid_row() {
let source = " | Name | Score |\n";
let outcome = prev(source, 0, 14);
assert_eq!(outcome, TableNavOutcome::moved(0, 6));
}
#[test]
fn prev_from_first_cell_wraps_to_previous_row() {
let source = " | A | B |\n | C | D |\n";
let outcome = prev(source, 1, 7);
assert_eq!(outcome, TableNavOutcome::moved(0, 10));
}
#[test]
fn prev_from_first_row_first_cell_is_no_move() {
let source = " | A | B |\n";
let outcome = prev(source, 0, 7);
assert_eq!(outcome, TableNavOutcome::no_move());
}
#[test]
fn prev_at_line_zero_first_cell_does_not_underflow() {
let source = "| A | B |\n";
let outcome = prev(source, 0, 3);
assert_eq!(outcome, TableNavOutcome::no_move());
}
#[test]
fn does_not_wrap_across_non_pipe_row() {
let source = " | A | B |\nSome paragraph.\n | C | D |\n";
let outcome = next(source, 0, 11);
assert_eq!(outcome, TableNavOutcome::no_move());
}
#[test]
fn target_clamped_when_next_row_is_shorter_than_pipe_plus_two() {
let source = " | A | B |\n|\n";
let outcome = next(source, 0, 11);
assert_eq!(outcome, TableNavOutcome::moved(1, 1));
}
#[test]
fn cursor_exactly_on_leading_pipe_skips_adjacent_cell_for_next() {
let source = " | A | B | C |\n";
let outcome = next(source, 0, 4);
assert_eq!(outcome, TableNavOutcome::moved(0, 10));
}
#[test]
fn cursor_exactly_on_leading_pipe_wraps_to_previous_row_for_previous() {
let source = " | A | B | C |\n | D | E | F |\n";
let outcome = prev(source, 1, 4);
assert_eq!(outcome, TableNavOutcome::moved(0, 14));
}
#[test]
fn serializes_to_expected_shape() {
let outcome = TableNavOutcome::moved(3, 11);
let json = serde_json::to_value(&outcome).unwrap();
assert_eq!(
json,
serde_json::json!({ "inTable": true, "position": { "line": 3, "column": 11 } })
);
let outcome = TableNavOutcome::fallthrough();
let json = serde_json::to_value(&outcome).unwrap();
assert_eq!(
json,
serde_json::json!({ "inTable": false, "position": null })
);
let outcome = TableNavOutcome::no_move();
let json = serde_json::to_value(&outcome).unwrap();
assert_eq!(
json,
serde_json::json!({ "inTable": true, "position": null })
);
}
}