#![allow(clippy::single_range_in_vec_init)]
use std::time::{Duration, Instant};
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use edamame::config::{Action, Theme};
use edamame::document::Buffer;
use edamame::editor::{edit_ops, mouse_ops, EditorState, Mode};
use edamame::input::{MouseAction, MouseDispatcher};
use edamame::ui::table_view;
use ratatui::layout::Rect;
const VP: usize = 40;
const VW: usize = 80;
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
fn state(text: &str) -> EditorState {
EditorState::new(Buffer::from_str(text), theme())
}
fn click_event(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
fn ctrl_click_event(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::CONTROL,
}
}
fn click(col: u16, row: u16) -> MouseAction {
MouseAction::Click {
col,
row,
modifiers: KeyModifiers::NONE,
}
}
fn drag_event(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
fn up_event(col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
fn area() -> Rect {
Rect {
x: 0,
y: 0,
width: 80,
height: 24,
}
}
#[test]
fn click_on_paragraph_in_rendered_mode_places_cursor() {
let mut st = state("Hello, world!\n");
st.mode = Mode::Rendered;
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
let action = mouse
.dispatch(click_event(7, 0), area())
.expect("click dispatched");
assert!(matches!(action, MouseAction::Click { .. }));
mouse_ops::apply(&mut st, action, &mut anchor, &[], VP, VW);
assert_eq!(st.mode, Mode::Rendered);
assert_eq!(st.cursor.offset, 7); assert_eq!(
anchor,
Some(mouse_ops::DragTarget::TextSelection {
anchor: 7,
cell: None
})
);
}
#[test]
fn click_in_preview_seeds_visual_selection_without_mode_change() {
let mut st = state("Hello, world!\n");
assert_eq!(st.mode, Mode::Preview);
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
if let Some(a) = mouse.dispatch(click_event(7, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(st.mode, Mode::Preview);
let vs = st.visual_selection.expect("click seeds visual selection");
assert_eq!(vs.anchor, (0, 7));
assert_eq!(vs.active, (0, 7));
}
#[test]
fn wheel_scroll_does_not_move_cursor() {
let long = (0..40).map(|i| format!("line {i}\n")).collect::<String>();
let mut st = state(&long);
st.mode = Mode::Rendered;
let original_cursor = st.cursor.offset;
for _ in 0..5 {
mouse_ops::apply(&mut st, MouseAction::Scroll(3), &mut None, &[], VP, VW);
}
assert!(st.scroll > 0, "scroll did advance");
assert_eq!(st.cursor.offset, original_cursor, "cursor must stay put");
}
#[test]
fn wheel_scroll_can_go_past_last_line() {
let text = (0..10).map(|i| format!("l{i}\n")).collect::<String>();
let mut st = state(&text);
st.mode = Mode::Rendered;
let total = st.parsed.line_count();
for _ in 0..20 {
mouse_ops::apply(&mut st, MouseAction::Scroll(3), &mut None, &[], 5, VW);
}
assert_eq!(st.scroll, total.saturating_sub(1));
}
#[test]
fn same_line_click_does_not_set_drag_in_progress() {
let mut st = state("first paragraph here\n\nsecond paragraph here\n");
st.mode = Mode::Rendered;
st.cursor.offset = 0;
st.update_cursor_block();
st.cursor_block_entered_at = None; assert!(st.cursor_block_revealed());
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(10, 0), &mut anchor, &[], VP, VW);
assert_eq!(st.cursor_line_idx, Some(0));
assert!(
!st.drag_in_progress,
"same-line click must not suppress raw reveal",
);
assert!(st.cursor_block_revealed());
assert!(matches!(
anchor,
Some(mouse_ops::DragTarget::TextSelection { .. })
));
}
#[test]
fn cross_line_click_still_sets_drag_in_progress() {
let mut st = state("first paragraph here\n\nsecond paragraph here\n");
st.mode = Mode::Rendered;
st.cursor.offset = 0;
st.update_cursor_block();
st.cursor_block_entered_at = None;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(3, 2), &mut anchor, &[], VP, VW);
assert_ne!(st.cursor_line_idx, Some(0));
assert!(
st.drag_in_progress,
"cross-line click keeps drag-suppression so the new line shows rendered briefly",
);
}
fn revealed_on_first_line(src: &str) -> EditorState {
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = 0;
st.update_cursor_block();
st.cursor_block_entered_at = None;
assert!(st.cursor_block_revealed());
st
}
#[test]
fn a_drag_from_the_cursors_line_turns_the_reveal_off_until_mouse_up() {
let mut st = revealed_on_first_line("first paragraph here\n\nsecond paragraph here\n");
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(10, 0), &mut anchor, &[], VP, VW);
assert!(
st.cursor_block_revealed(),
"the press alone keeps the reveal"
);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 3, row: 2 },
&mut anchor,
&[],
VP,
VW,
);
assert!(st.drag_in_progress);
assert!(
!st.cursor_block_revealed(),
"a drag onto another line must not reveal it"
);
mouse_ops::apply(&mut st, MouseAction::Release, &mut anchor, &[], VP, VW);
assert!(
!st.cursor_block_revealed(),
"the reveal waits out the delay after mouse-up",
);
st.cursor_block_entered_at = Some(Instant::now() - edamame::editor::RAW_REVEAL_DELAY);
assert!(st.cursor_block_revealed());
}
#[test]
fn a_drag_along_the_cursors_line_collapses_it_until_the_delay_after_mouse_up() {
let mut st = revealed_on_first_line("first paragraph here\n\nsecond paragraph here\n");
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(2, 0), &mut anchor, &[], VP, VW);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 12, row: 0 },
&mut anchor,
&[],
VP,
VW,
);
assert_eq!(st.cursor_line_idx, Some(0));
assert!(!st.cursor_block_revealed());
mouse_ops::apply(&mut st, MouseAction::Release, &mut anchor, &[], VP, VW);
assert!(
!st.cursor_block_revealed(),
"a drag that never left its line still re-arms the delay",
);
}
#[test]
fn a_drag_that_does_not_move_the_cursor_keeps_the_reveal() {
let mut st = revealed_on_first_line("first paragraph here\n\nsecond paragraph here\n");
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(10, 0), &mut anchor, &[], VP, VW);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 10, row: 0 },
&mut anchor,
&[],
VP,
VW,
);
assert!(!st.drag_in_progress);
mouse_ops::apply(&mut st, MouseAction::Release, &mut anchor, &[], VP, VW);
assert!(
st.cursor_block_revealed(),
"a press-release on the cursor's line keeps it"
);
}
#[test]
fn same_line_click_inside_table_still_sets_drag_in_progress() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = 2;
st.update_cursor_block();
st.cursor_block_entered_at = None;
let cursor_line = st.cursor_line_idx;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(7, 1), &mut anchor, &[], VP, VW);
assert_eq!(st.cursor_line_idx, cursor_line);
assert!(
st.drag_in_progress,
"table click must still suppress reveal so the active cell can swap",
);
}
#[test]
fn click_in_table_cell_with_code_span_maps_through_hidden_backticks() {
let src = "| x | `code` word |\n|---|---|\n| a | b |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let (row, line_text) = st
.parsed
.lines
.iter()
.enumerate()
.map(|(i, l)| {
(
i,
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>(),
)
})
.find(|(_, text)| text.contains("code word"))
.expect("header row renders the collapsed cell text");
assert!(
!line_text.contains('`'),
"backticks must be hidden in the rendered cell: {line_text:?}",
);
let byte = line_text.find("word").expect("rendered 'word'");
let w_col = line_text[..byte].chars().count() as u16;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(w_col, row as u16), &mut anchor, &[], VP, VW);
assert_eq!(st.cursor.offset, 13);
assert_eq!(
st.buffer
.slice_to_string(st.cursor.offset, st.cursor.offset + 1),
"w",
);
}
#[test]
fn click_on_a_table_inside_a_list_item_lands_in_the_clicked_cell() {
let src = "- item\n\n | a | b |\n |---|---|\n | 1 | 2 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let (col, row) = rendered_cell_of(&st, '2');
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(col, row), &mut anchor, &[], VP, VW);
assert_eq!(st.cursor.offset, src.rfind('2').unwrap());
}
#[test]
fn a_click_lands_on_the_clicked_char_whatever_the_rows_pipes() {
let cases = [
("a | b\n--|--\n1 | 2\n", "ab12"),
("| a | b |\n|---|---|\n1 | 2 |\n", "ab12"),
("| a | b |\n|---|---|\n| 1 | 2\n", "ab12"),
(
"| a | b | c |\n|---|---|---|\n| 1 | 2 | 3 | 4 | 5 |\n",
"abc123",
),
("> a | b\n> --|--\n> 1 | 2\n", "ab12"),
(
"x[^a|b]\n\n[^a|b]: | p | q |\n |---|---|\n | 1 | 2 |\n",
"pq12",
),
];
for (src, chars) in cases {
for ch in chars.chars() {
let mut st = state(src);
st.mode = Mode::Rendered;
let (col, row) = rendered_cell_of(&st, ch);
mouse_ops::apply(&mut st, click(col, row), &mut None, &[], VP, VW);
assert_eq!(
st.buffer.contents().chars().nth(st.cursor.offset),
Some(ch),
"clicking {ch:?} in {src:?}",
);
}
}
}
#[test]
fn a_click_in_a_cell_a_short_row_lacks_lands_at_the_rows_end() {
for src in [
"| a | b | c |\n|---|---|---|\n| 1 |\n",
"| a | b | c |\n|---|---|---|\n| 1 | \n",
"| a | b | c |\n|---|---|---|\n| 1\n",
"| a | b | c |\n|---|---|---|\n| 1 \n",
] {
let mut st = state(src);
st.mode = Mode::Rendered;
let (col, row) = rendered_cell_of(&st, '1');
mouse_ops::apply(&mut st, click(col + 4, row), &mut None, &[], VP, VW);
assert_eq!(st.cursor.offset, src.trim_end().len(), "in {src:?}");
}
}
#[test]
fn same_line_click_inside_a_nested_table_still_sets_drag_in_progress() {
let src = "- item\n\n | a | b |\n |---|---|\n | 1 | 2 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = src.rfind('1').unwrap();
st.update_cursor_block();
st.cursor_block_entered_at = None;
let cursor_line = st.cursor_line_idx;
let (col, row) = rendered_cell_of(&st, '2');
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(col, row), &mut anchor, &[], VP, VW);
assert_eq!(st.cursor_line_idx, cursor_line);
assert!(st.drag_in_progress);
}
#[test]
fn a_drag_from_a_table_cell_is_clamped_to_it_at_any_depth() {
for src in [
"| a | b |\n|---|---|\n| 1 | 2 |\n",
"- item\n\n | a | b |\n |---|---|\n | 1 | 2 |\n",
"> | a | b |\n> |---|---|\n> | 1 | 2 |\n",
] {
let mut st = state(src);
st.mode = Mode::Rendered;
let (col, row) = rendered_cell_of(&st, '2');
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(col, row), &mut anchor, &[], VP, VW);
let two = src.rfind('2').unwrap();
assert_eq!(
anchor,
Some(mouse_ops::DragTarget::TextSelection {
anchor: two,
cell: Some((two - 1, two + 2)),
}),
"{src:?}"
);
}
}
#[test]
fn a_drag_from_a_cell_without_edge_pipes_is_clamped_to_it() {
for (src, ch, cell) in [
("a | b |\n--|--|\n1 | 2 |\n", '1', (0, 2)),
("a | b\n--|--\n1 | 2\n", '2', (-1, 1)),
(
"x[^a|b]\n\n[^a|b]: | p | q |\n |---|---|\n | 1 | 2 |\n",
'2',
(-1, 2),
),
] {
let mut st = state(src);
st.mode = Mode::Rendered;
let (col, row) = rendered_cell_of(&st, ch);
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(col, row), &mut anchor, &[], VP, VW);
let at = src.rfind(ch).unwrap();
let (from, to) = cell;
assert_eq!(
anchor,
Some(mouse_ops::DragTarget::TextSelection {
anchor: at,
cell: Some(((at as isize + from) as usize, (at as isize + to) as usize)),
}),
"{src:?}"
);
}
}
#[test]
fn click_on_a_table_border_lands_in_the_cell_beside_it() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
for (row, ch) in [(0u16, 'b'), (2, '2'), (4, '2'), (6, '4')] {
let mut st = state(src);
st.mode = Mode::Rendered;
let (col, _) = rendered_cell_of(&st, ch);
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(col, row), &mut anchor, &[], VP, VW);
assert_eq!(
st.cursor.offset,
src.find(ch).unwrap(),
"a click on row {row} under {ch:?}"
);
}
}
#[test]
fn clicks_on_a_wrapped_quoted_table_land_under_the_pointer() {
use edamame::ui::{RenderedView, RenderedViewState};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
const W: u16 = 14;
const H: u16 = 20;
let src = "intro\n\n> | k | v |\n> |---|---|\n> | x | aa bb cc dd ee |\n";
let build = || {
let mut st = state(src);
st.mode = Mode::Rendered;
st.set_viewport_width(W as usize);
st
};
let click_at = |col: u16, row: u16| {
let mut st = build();
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(col, row), &mut anchor, &[], VP, W as usize);
st.cursor.offset
};
let st = build();
let theme = theme();
let mut terminal = Terminal::new(TestBackend::new(W, H)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &st,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let symbol = |col: u16, row: u16| {
buf.cell((col, row))
.and_then(|c| c.symbol().chars().next())
.unwrap_or(' ')
};
let mut checked = 0;
for row in 1..H {
for col in 0..W {
let painted = symbol(col, row);
if !painted.is_ascii_alphanumeric() {
continue;
}
let at = click_at(col, row);
let landed = src.chars().nth(at);
assert_eq!(
landed,
Some(painted),
"click at (col {col}, row {row}) shows {painted:?} but landed on {landed:?} \
(offset {at})"
);
checked += 1;
}
}
assert!(
checked >= 12,
"expected the table's chars to check, got {checked}"
);
let x_row = (0..H)
.find(|&y| (0..W).any(|x| symbol(x, y) == 'x'))
.expect("the data row is painted");
let pipe_col = (0..W)
.find(|&x| symbol(x, x_row + 1) == '│')
.expect("fixture: the data row wraps its closing pipe");
let at = click_at(pipe_col, x_row + 1);
assert!(
at >= src.find("aa").unwrap(),
"a click on the continuation's pipe landed at {at}, in a cell left of `aa`"
);
}
fn rendered_cell_of(st: &EditorState, ch: char) -> (u16, u16) {
let (row, text) = st
.parsed
.lines
.iter()
.enumerate()
.map(|(i, l)| {
(
i,
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>(),
)
})
.find(|(_, text)| text.contains('│') && text.contains(ch))
.expect("the table row renders");
let col = text[..text.find(ch).unwrap()].chars().count();
(col as u16, row as u16)
}
#[test]
fn click_drag_extends_selection_anchor_to_release_position() {
let mut st = state("the quick brown fox\n");
st.mode = Mode::Rendered;
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
if let Some(a) = mouse.dispatch(click_event(0, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
if let Some(a) = mouse.dispatch(drag_event(10, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
if let Some(a) = mouse.dispatch(up_event(10, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
let sel = st.selection.expect("drag creates selection");
assert_eq!(sel.anchor, 0);
assert_eq!(sel.active, 10);
let (start, end) = sel.range();
let selected = st.buffer.slice_to_string(start, end);
assert_eq!(selected, "the quick ");
}
#[test]
fn double_click_selects_word_under_pointer() {
let mut st = state("alpha bravo charlie\n");
st.mode = Mode::Rendered;
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
if let Some(a) = mouse.dispatch(click_event(7, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
if let Some(a) = mouse.dispatch(up_event(7, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
if let Some(a) = mouse.dispatch(click_event(7, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
let sel = st.selection.expect("double-click selects");
let (s, e) = sel.range();
assert_eq!(st.buffer.slice_to_string(s, e), "bravo");
}
#[test]
fn double_click_keeps_a_combining_mark_in_the_word() {
let mut st = state("x cafe\u{301}s y\n");
st.mode = Mode::Rendered;
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
for ev in [click_event(3, 0), up_event(3, 0), click_event(3, 0)] {
if let Some(a) = mouse.dispatch(ev, area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
}
let sel = st.selection.expect("double-click selects");
let (s, e) = sel.range();
assert_eq!(st.buffer.slice_to_string(s, e), "cafe\u{301}s");
}
#[test]
fn triple_click_selects_line() {
let mut st = state("first line\nsecond line\nthird line\n");
st.mode = Mode::Rendered;
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
for _ in 0..3 {
if let Some(a) = mouse.dispatch(click_event(3, 1), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
if let Some(a) = mouse.dispatch(up_event(3, 1), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
}
let sel = st.selection.expect("triple-click selects line");
let (s, e) = sel.range();
let text = st.buffer.slice_to_string(s, e);
assert!(
text.contains("second"),
"selected text should contain the clicked line, got: {text:?}"
);
}
#[test]
fn click_on_checkbox_in_task_list_toggles_it() {
let mut st = state("- [ ] todo one\n- [x] todo two\n");
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(1, 0), &mut anchor, &[], VP, VW);
assert!(st.contents().starts_with("- [x] todo one"));
mouse_ops::apply(&mut st, click(1, 1), &mut anchor, &[], VP, VW);
assert!(st.contents().contains("- [ ] todo two"));
}
#[test]
fn click_on_checkbox_on_another_line_toggles_without_moving_cursor() {
let mut st = state("- [ ] todo one\n- [ ] todo two\n");
st.mode = Mode::Rendered;
st.cursor.offset = 0;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(0, 1), &mut anchor, &[], VP, VW);
assert!(
st.contents().contains("- [x] todo two"),
"expected second checkbox to flip, got: {:?}",
st.contents()
);
assert!(
st.cursor.offset <= 15, "cursor moved into the clicked line: offset {}",
st.cursor.offset
);
}
#[test]
fn triple_click_in_table_cell_selects_only_cell_content() {
let mut st = state("| a | b | c |\n|---|---|---|\n| alpha | bravo | charlie |\n");
st.mode = Mode::Rendered;
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
for _ in 0..3 {
if let Some(a) = mouse.dispatch(click_event(11, 3), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
if let Some(a) = mouse.dispatch(up_event(11, 3), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
}
let sel = st.selection.expect("triple-click sets selection");
let (s, e) = sel.range();
assert_eq!(
st.buffer.slice_to_string(s, e),
"bravo",
"triple-click in table cell should select only the trimmed cell content"
);
}
#[test]
fn click_on_table_cell_stays_in_that_cell_despite_padding() {
let mut st = state("| longheader | short | c |\n|---|---|---|\n| a | b | c |\n");
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(18, 3), &mut anchor, &[], VP, VW);
let raw_start_of_data_row = "| longheader | short | c |\n|---|---|---|\n".len();
let cursor_byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let offset_in_row = cursor_byte - raw_start_of_data_row;
assert!(
(5..=9).contains(&offset_in_row),
"cursor offset in row = {} (expected inside cell 1 byte range 5..=9)",
offset_in_row
);
}
#[test]
fn click_on_thick_header_separator_redirects_to_first_data_row() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(2, 2), &mut anchor, &[], VP, VW);
let cursor_byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let first_data_start = "| a | b |\n|---|---|\n".len();
let first_data_end = first_data_start + "| 1 | 2 |".len();
assert!(
(first_data_start..=first_data_end).contains(&cursor_byte),
"cursor landed at byte {cursor_byte}; expected inside first data row \
[{first_data_start}..={first_data_end}]"
);
}
#[test]
fn click_on_second_data_row_lands_on_second_data_row() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(2, 5), &mut anchor, &[], VP, VW);
let cursor_byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let second_data_start = "| a | b |\n|---|---|\n| 1 | 2 |\n".len();
let second_data_end = second_data_start + "| 3 | 4 |".len();
assert!(
(second_data_start..=second_data_end).contains(&cursor_byte),
"cursor landed at byte {cursor_byte}; expected inside second data row \
[{second_data_start}..={second_data_end}]"
);
}
#[test]
fn click_on_thin_inter_row_separator_snaps_to_preceding_data_row() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(2, 4), &mut anchor, &[], VP, VW);
let cursor_byte = st.buffer.rope().char_to_byte(st.cursor.offset);
let first_data_start = "| a | b |\n|---|---|\n".len();
let first_data_end = first_data_start + "| 1 | 2 |".len();
assert!(
(first_data_start..=first_data_end).contains(&cursor_byte),
"cursor landed at byte {cursor_byte}; expected inside first data row"
);
}
#[test]
fn drag_release_expands_selection_to_enclosing_markers() {
use edamame::document::Selection;
let mut st = state("alpha *cat* beta\n");
st.mode = Mode::Rendered;
st.selection = Some(Selection {
anchor: 7,
active: 10,
});
let mut anchor: Option<mouse_ops::DragTarget> = Some(mouse_ops::DragTarget::TextSelection {
anchor: 0,
cell: None,
});
mouse_ops::apply(&mut st, MouseAction::Release, &mut anchor, &[], VP, VW);
let sel = st.selection.expect("selection still present");
let (s, e) = sel.range();
assert_eq!(
st.buffer.slice_to_string(s, e),
"*cat*",
"selection should include the `*` markers after release"
);
}
#[test]
fn drag_release_expands_double_markers_to_strong() {
use edamame::document::Selection;
let mut st = state("plain **bold** text\n");
st.mode = Mode::Rendered;
st.selection = Some(Selection {
anchor: 8,
active: 12,
});
let mut anchor: Option<mouse_ops::DragTarget> = Some(mouse_ops::DragTarget::TextSelection {
anchor: 0,
cell: None,
});
mouse_ops::apply(&mut st, MouseAction::Release, &mut anchor, &[], VP, VW);
let sel = st.selection.unwrap();
let (s, e) = sel.range();
assert_eq!(st.buffer.slice_to_string(s, e), "**bold**");
}
#[test]
fn preview_click_drag_produces_rendered_text_on_copy() {
use edamame::editor::mouse_ops::visual_selection_to_rendered_text;
let mut st = state("alpha *emph* beta\n");
assert_eq!(st.mode, Mode::Preview);
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
if let Some(a) = mouse.dispatch(click_event(6, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
if let Some(a) = mouse.dispatch(drag_event(10, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
if let Some(a) = mouse.dispatch(up_event(10, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(st.mode, Mode::Preview);
let vs = st.visual_selection.expect("drag creates visual selection");
let text = visual_selection_to_rendered_text(vs, &st.parsed.lines);
assert_eq!(text, "emph");
}
#[test]
fn hit_test_returns_true_over_task_bullet_and_checkbox() {
let st = state("- [ ] first\n- [x] second\n");
for (row, col) in [
(0u16, 0u16),
(0, 1),
(0, 2),
(0, 3),
(0, 4),
(1, 0),
(1, 1),
(1, 2),
(1, 3),
(1, 4),
] {
assert!(
mouse_ops::hit_test_clickable(&st, col, row, VW, &[]),
"expected hit-test TRUE at ({col}, {row})"
);
}
assert!(!mouse_ops::hit_test_clickable(&st, 5, 0, VW, &[]));
assert!(!mouse_ops::hit_test_clickable(&st, 7, 0, VW, &[]));
}
#[test]
fn hit_test_returns_true_over_markdown_link() {
let st = state("See [docs](https://x.com) for info.\n");
assert!(mouse_ops::hit_test_clickable(&st, 5, 0, VW, &[])); assert!(mouse_ops::hit_test_clickable(&st, 7, 0, VW, &[])); assert!(!mouse_ops::hit_test_clickable(&st, 2, 0, VW, &[]));
assert!(!mouse_ops::hit_test_clickable(&st, 10, 0, VW, &[]));
}
#[test]
fn hit_test_returns_true_over_heading_anchor_link() {
let st = state("See [Setup](#setup) below.\n");
assert!(mouse_ops::hit_test_clickable(&st, 4, 0, VW, &[]));
assert!(mouse_ops::hit_test_clickable(&st, 8, 0, VW, &[]));
assert!(!mouse_ops::hit_test_clickable(&st, 1, 0, VW, &[]));
assert!(!mouse_ops::hit_test_clickable(&st, 12, 0, VW, &[]));
}
#[test]
fn hovered_link_url_resolves_heading_anchor() {
let st = state("See [Setup](#setup) below.\n");
assert_eq!(
mouse_ops::hovered_link_url(&st, 5, 0, VW).as_deref(),
Some("#setup")
);
assert_eq!(mouse_ops::hovered_link_url(&st, 1, 0, VW), None);
}
#[test]
fn hit_test_returns_true_over_each_table_button() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let st = state(src);
let mut snap =
snapshot_with_delete_handles(src, vec![2..5, 6..9], vec![3..4], Some(9), Some(4));
snap.row_handle_col = Some(0);
snap.top_border_row = Some(0);
let snapshots = [snap];
assert!(
mouse_ops::hit_test_clickable(&st, 0, 3, VW, &snapshots),
"row-reorder handle should classify as clickable",
);
assert!(
mouse_ops::hit_test_clickable(&st, 3, 0, VW, &snapshots),
"column-reorder handle should classify as clickable",
);
assert!(
mouse_ops::hit_test_clickable(&st, 9, 3, VW, &snapshots),
"row-delete glyph should classify as clickable",
);
assert!(
mouse_ops::hit_test_clickable(&st, 7, 4, VW, &snapshots),
"column-delete glyph should classify as clickable",
);
assert!(
mouse_ops::hit_test_clickable(&st, 5, 3, VW, &snapshots),
"interior resize border should classify as clickable",
);
assert!(
!mouse_ops::hit_test_clickable(&st, 1, 3, VW, &snapshots),
"leftmost outer border should NOT classify as clickable",
);
}
#[test]
fn rapid_clicks_and_drags_do_not_crash() {
let mut st = state("short doc\n");
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
for row in 0..5u16 {
for col in 0..20u16 {
if let Some(a) = mouse.dispatch(click_event(col, row), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
if let Some(a) = mouse.dispatch(drag_event(col + 1, row), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
if let Some(a) = mouse.dispatch(up_event(col + 1, row), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
}
}
}
#[test]
fn link_at_offset_detection_works_via_public_api() {
let src = "See [the docs](https://example.com) for more.\n";
assert_eq!(
mouse_ops::link_at_offset(src, 8),
Some("https://example.com".to_owned())
);
assert_eq!(mouse_ops::link_at_offset(src, 1), None);
}
use edamame::ui::table_view::TableLayoutSnapshot;
struct FakeSnapshotSpec {
table_byte_start: usize,
table_byte_end: usize,
col_count: usize,
row_count: usize,
col_ranges: Vec<std::ops::Range<u16>>,
row_ranges: Vec<std::ops::Range<u16>>,
row_handle_col: Option<u16>,
top_border_row: Option<u16>,
}
fn fake_snapshot(spec: FakeSnapshotSpec) -> TableLayoutSnapshot {
TableLayoutSnapshot {
table_byte_start: spec.table_byte_start,
table_byte_end: spec.table_byte_end,
col_count: spec.col_count,
row_count: spec.row_count,
col_ranges: spec.col_ranges,
row_ranges: spec.row_ranges,
row_handle_col: spec.row_handle_col,
top_border_row: spec.top_border_row,
header_row: None,
delete_row_handle_col: None,
bottom_border_row: None,
}
}
#[test]
fn row_handle_drag_swaps_rows_in_buffer() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 4,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![3..4, 5..6], row_handle_col: Some(0),
top_border_row: Some(0),
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(0, 3), &mut target, &snapshots, VP, VW);
assert!(matches!(
target,
Some(mouse_ops::DragTarget::TableRow { row_idx: 2, .. })
));
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 0, row: 5 },
&mut target,
&snapshots,
VP,
VW,
);
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
let after = st.contents();
let row_order: Vec<&str> = after.lines().skip(2).collect();
assert_eq!(row_order[0], "| 3 | 4 |");
assert_eq!(row_order[1], "| 1 | 2 |");
}
#[test]
fn a_multi_row_drag_undoes_in_one_step() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n| 5 | 6 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 5,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![3..4, 5..6, 7..8],
row_handle_col: Some(0),
top_border_row: Some(0),
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(0, 3), &mut target, &snapshots, VP, VW);
for row in [5, 7] {
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 0, row },
&mut target,
&snapshots,
VP,
VW,
);
}
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
let moved = st.contents();
let rows: Vec<&str> = moved.lines().skip(2).collect();
assert_eq!(rows, ["| 3 | 4 |", "| 5 | 6 |", "| 1 | 2 |"]);
edit_ops::apply(&mut st, Action::Undo, VP, VW);
assert_eq!(st.contents(), src, "one undo must restore the whole drag");
}
#[test]
fn a_multi_column_drag_undoes_in_one_step() {
let src = "| a | b | c |\n|---|---|---|\n| 1 | 2 | 3 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 3,
row_count: 3,
col_ranges: vec![2..5, 6..9, 10..13],
row_ranges: vec![3..4],
row_handle_col: None,
top_border_row: Some(0),
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(3, 0), &mut target, &snapshots, VP, VW);
for col in [7, 11] {
mouse_ops::apply(
&mut st,
MouseAction::Drag { col, row: 0 },
&mut target,
&snapshots,
VP,
VW,
);
}
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
let moved = st.contents();
let lines: Vec<&str> = moved.lines().collect();
assert_eq!(
lines,
["| b | c | a |", "|---|---|---|", "| 2 | 3 | 1 |"],
"column 0 should have moved to the end, got: {moved:?}"
);
edit_ops::apply(&mut st, Action::Undo, VP, VW);
assert_eq!(st.contents(), src, "one undo must restore the whole drag");
}
#[test]
fn a_multi_row_drag_upward_undoes_in_one_step() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n| 5 | 6 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 5,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![3..4, 5..6, 7..8],
row_handle_col: Some(0),
top_border_row: Some(0),
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(0, 7), &mut target, &snapshots, VP, VW);
for row in [5, 3] {
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 0, row },
&mut target,
&snapshots,
VP,
VW,
);
}
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
let moved = st.contents();
let rows: Vec<&str> = moved.lines().skip(2).collect();
assert_eq!(rows, ["| 5 | 6 |", "| 1 | 2 |", "| 3 | 4 |"]);
edit_ops::apply(&mut st, Action::Undo, VP, VW);
assert_eq!(st.contents(), src, "one undo must restore the whole drag");
}
#[test]
fn row_drag_hover_follows_the_pointer_over_separators_and_borders() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 4,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![3..4, 5..6],
row_handle_col: Some(0),
top_border_row: Some(0),
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(0, 3), &mut target, &snapshots, VP, VW);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 0, row: 4 },
&mut target,
&snapshots,
VP,
VW,
);
assert!(
matches!(
target,
Some(mouse_ops::DragTarget::TableRow {
hover_row_idx: 2,
..
})
),
"separator hover should resolve to a data row, got: {target:?}"
);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 5, row: 5 },
&mut target,
&snapshots,
VP,
VW,
);
assert!(
matches!(
target,
Some(mouse_ops::DragTarget::TableRow {
hover_row_idx: 3,
..
})
),
"border hover should still track the row under the pointer, got: {target:?}"
);
}
#[test]
fn column_drag_hover_follows_the_pointer_over_border_vertices() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 3,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![3..4],
row_handle_col: None,
top_border_row: Some(0),
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(3, 0), &mut target, &snapshots, VP, VW);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 5, row: 0 },
&mut target,
&snapshots,
VP,
VW,
);
assert!(
matches!(
target,
Some(mouse_ops::DragTarget::TableColumnHeader {
hover_col_idx: 0,
..
})
),
"vertex hover should resolve to a column, got: {target:?}"
);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 7, row: 0 },
&mut target,
&snapshots,
VP,
VW,
);
assert!(matches!(
target,
Some(mouse_ops::DragTarget::TableColumnHeader {
hover_col_idx: 1,
..
})
));
}
#[test]
fn multi_click_on_a_row_handle_still_arms_the_drag() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 4,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![3..4, 5..6],
row_handle_col: Some(0),
top_border_row: Some(0),
});
let snapshots = [snap];
for action in [
MouseAction::DoubleClick {
col: 0,
row: 3,
modifiers: KeyModifiers::NONE,
},
MouseAction::TripleClick {
col: 0,
row: 3,
modifiers: KeyModifiers::NONE,
},
] {
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, action, &mut target, &snapshots, VP, VW);
assert!(
matches!(
target,
Some(mouse_ops::DragTarget::TableRow { row_idx: 2, .. })
),
"expected a re-grab to arm the row drag, got: {target:?}"
);
}
}
#[test]
fn rapid_repeat_on_a_delete_handle_does_not_delete_twice() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let mut snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 4,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![3..4, 5..6],
row_handle_col: Some(0),
top_border_row: Some(0),
});
snap.delete_row_handle_col = Some(9);
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(9, 3), &mut target, &snapshots, VP, VW);
let after_first = st.contents();
assert!(!after_first.contains("| 1 | 2 |"), "first click deletes");
mouse_ops::apply(
&mut st,
MouseAction::DoubleClick {
col: 9,
row: 3,
modifiers: KeyModifiers::NONE,
},
&mut target,
&snapshots,
VP,
VW,
);
assert_eq!(
st.contents(),
after_first,
"a repeat press on the delete handle must not delete another row"
);
}
#[test]
fn a_deliberate_repeat_on_a_delete_handle_deletes_again() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let mut snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 4,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![3..4, 5..6],
row_handle_col: Some(0),
top_border_row: Some(0),
});
snap.delete_row_handle_col = Some(9);
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(9, 3), &mut target, &snapshots, VP, VW);
assert!(!st.contents().contains("| 1 | 2 |"), "first click deletes");
st.last_table_delete_at = Some(Instant::now() - Duration::from_millis(400));
mouse_ops::apply(
&mut st,
MouseAction::TripleClick {
col: 9,
row: 3,
modifiers: KeyModifiers::NONE,
},
&mut target,
&snapshots,
VP,
VW,
);
assert!(
!st.contents().contains("| 3 | 4 |"),
"once the cooldown lapses the handle deletes again, however the \
dispatcher classified the press: {:?}",
st.contents()
);
}
#[test]
fn column_handle_on_an_unfocused_table_focuses_it_instead_of_arming() {
let src = "intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\n";
let table_start = src.find("| a |").unwrap();
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = 0;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: table_start,
table_byte_end: src.len(),
col_count: 2,
row_count: 3,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![5..6],
row_handle_col: Some(0),
top_border_row: Some(2),
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(3, 2), &mut target, &snapshots, VP, VW);
assert!(
target.is_none(),
"a press on an unfocused table's top border must not arm a reorder, got: {target:?}"
);
assert_eq!(
st.buffer.rope().char_to_byte(st.cursor.offset),
table_start,
"it focuses the table instead"
);
mouse_ops::apply(&mut st, click(3, 2), &mut target, &snapshots, VP, VW);
assert!(
matches!(
target,
Some(mouse_ops::DragTarget::TableColumnHeader { col_idx: 0, .. })
),
"expected the second press to arm the column drag, got: {target:?}"
);
}
#[test]
fn delete_handle_on_an_unfocused_table_focuses_it_instead_of_deleting() {
let src = "intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\n";
let table_start = src.find("| a |").unwrap();
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = 0;
let mut snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: table_start,
table_byte_end: src.len(),
col_count: 2,
row_count: 3,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![5..6],
row_handle_col: Some(0),
top_border_row: Some(2),
});
snap.delete_row_handle_col = Some(9);
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(9, 5), &mut target, &snapshots, VP, VW);
assert_eq!(st.contents(), src, "first click must not delete");
assert_eq!(
st.buffer.rope().char_to_byte(st.cursor.offset),
table_start,
"first click focuses the table"
);
mouse_ops::apply(&mut st, click(9, 5), &mut target, &snapshots, VP, VW);
assert!(!st.contents().contains("| 1 | 2 |"), "second click deletes");
}
#[test]
fn column_border_drag_writes_tui_columns_comment() {
let src = "| headerA | headerB |\n|-----|-----|\n| foo | bar |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 3,
col_ranges: vec![2..12, 13..23],
row_ranges: vec![3..4],
row_handle_col: None,
top_border_row: None,
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(12, 3), &mut target, &snapshots, VP, VW);
assert!(matches!(
target,
Some(mouse_ops::DragTarget::TableColumnBorder { col_idx: 1, .. })
));
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 14, row: 3 },
&mut target,
&snapshots,
VP,
VW,
);
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
assert!(st.has_pending_column_widths());
st.commit_pending_column_widths();
let after = st.contents();
assert!(
after.contains("<!-- tui-columns:"),
"expected tui-columns comment after release, got: {after:?}"
);
}
#[test]
fn column_border_drag_widens_table_and_leaves_neighbour_auto() {
let src = "| abc | defghi |\n| --- | --- |\n| bar | baz |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 3,
col_ranges: vec![1..6, 7..15],
row_ranges: vec![3..4],
row_handle_col: None,
top_border_row: None,
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(6, 3), &mut target, &snapshots, VP, VW);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 8, row: 3 },
&mut target,
&snapshots,
VP,
VW,
);
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
st.commit_pending_column_widths();
let after = st.contents();
assert!(
after.contains("<!-- tui-columns: [5, _] -->"),
"expected col 0 pinned to 5 and col 1 auto, got: {after:?}"
);
}
#[test]
fn column_border_drag_caps_an_offset_table_at_the_viewport_edge() {
let src = "x[^n]\n\n[^n]: lead\n\n | abc | defghi |\n | --- | --- |\n | bar | baz |\n";
let table_start = src.find(" | abc").unwrap();
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = table_start + 6;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: table_start,
table_byte_end: src.len(),
col_count: 2,
row_count: 3,
col_ranges: vec![7..12, 13..21],
row_ranges: vec![9..10],
row_handle_col: None,
top_border_row: None,
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(21, 9), &mut target, &snapshots, VP, VW);
mouse_ops::apply(
&mut st,
MouseAction::Drag {
col: VW as u16 + 20,
row: 9,
},
&mut target,
&snapshots,
VP,
VW,
);
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
st.commit_pending_column_widths();
let after = st.contents();
assert!(
after.contains("<!-- tui-columns: [_, 64] -->"),
"expected the last column capped at 64, got: {after:?}"
);
}
#[test]
fn right_outer_border_drag_resizes_last_column() {
let src = "| abc | defghi |\n| --- | --- |\n| bar | baz |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 3,
col_ranges: vec![1..6, 7..15],
row_ranges: vec![3..4],
row_handle_col: None,
top_border_row: None,
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(15, 3), &mut target, &snapshots, VP, VW);
assert!(
matches!(
target,
Some(mouse_ops::DragTarget::TableColumnBorder { col_idx: 2, .. })
),
"expected ColumnBorder {{ col_idx: 2 }} (right outer), got: {target:?}"
);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 18, row: 3 },
&mut target,
&snapshots,
VP,
VW,
);
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
st.commit_pending_column_widths();
let after = st.contents();
assert!(
after.contains("<!-- tui-columns: [_, 9] -->"),
"expected col 0 auto and col 1 pinned to 9, got: {after:?}"
);
}
#[test]
fn column_handle_drag_swaps_columns_in_buffer() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 3,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![3..4],
row_handle_col: None,
top_border_row: Some(0),
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(3, 0), &mut target, &snapshots, VP, VW);
assert!(matches!(
target,
Some(mouse_ops::DragTarget::TableColumnHeader { col_idx: 0, .. })
));
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 7, row: 0 },
&mut target,
&snapshots,
VP,
VW,
);
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
let after = st.contents();
assert!(
after.starts_with("| b | a |"),
"expected swapped header, got: {after:?}"
);
}
#[test]
fn column_reorder_leaves_cursor_off_persisted_comment_line() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n<!-- tui-columns: [4, 5] -->\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 3,
col_ranges: vec![1..4, 5..8],
row_ranges: vec![3..4],
row_handle_col: None,
top_border_row: Some(0),
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(2, 0), &mut target, &snapshots, VP, VW);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 6, row: 0 },
&mut target,
&snapshots,
VP,
VW,
);
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
let source = st.contents();
let comment_start = source.find("<!--").expect("comment preserved");
let cursor_byte = st.buffer.rope().char_to_byte(st.cursor.offset);
assert!(
cursor_byte < comment_start,
"cursor leaked onto comment line at byte {cursor_byte} (comment starts at {comment_start})",
);
}
#[test]
fn row_reorder_leaves_cursor_off_persisted_comment_line() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n<!-- tui-columns: [4, 5] -->\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 4,
col_ranges: vec![2..5, 6..9],
row_ranges: vec![3..4, 5..6],
row_handle_col: Some(0),
top_border_row: None,
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(0, 3), &mut target, &snapshots, VP, VW);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 0, row: 5 },
&mut target,
&snapshots,
VP,
VW,
);
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
let source = st.contents();
let comment_start = source.find("<!--").expect("comment preserved");
let cursor_byte = st.buffer.rope().char_to_byte(st.cursor.offset);
assert!(
cursor_byte < comment_start,
"cursor leaked onto comment line at byte {cursor_byte} (comment starts at {comment_start})",
);
}
use edamame::editor::link::LinkTarget;
#[test]
fn preview_click_on_link_sets_pending_follow_target() {
let mut st = state("See [docs](https://example.com) for more.\n");
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(5, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
let pending = st.pending_link_follow.take().expect("follow target set");
assert!(
matches!(&pending, LinkTarget::Url(u) if u == "https://example.com"),
"expected URL target, got {pending:?}"
);
}
#[test]
fn plain_click_in_rendered_on_link_places_cursor_does_not_follow() {
let mut st = state("See [docs](https://example.com) for more.\n");
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(5, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert!(st.pending_link_follow.is_none());
assert!(st.selection.is_none());
}
#[test]
fn click_inside_rendered_link_text_lands_on_clicked_char() {
let src = "first line\nSee [docs](https://example.com) for more.\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(5, 1), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.contents().chars().nth(st.cursor.offset),
Some('o'),
"expected cursor on 'o' of 'docs', landed at offset {} ({:?})",
st.cursor.offset,
st.contents().chars().nth(st.cursor.offset),
);
}
#[test]
fn click_on_revealed_link_line_lands_on_raw_char() {
let src = "See [docs](https://example.com) for more.\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(11, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.contents().chars().nth(st.cursor.offset),
Some('h'),
"expected cursor on 'h' of 'https', landed at offset {} ({:?})",
st.cursor.offset,
st.contents().chars().nth(st.cursor.offset),
);
}
#[test]
fn click_on_revealed_loose_list_item_with_inline_code_lands_on_raw_char() {
let src = "- Alpha item\n\n- Beta item\n\n- Gamma `code` tail\n";
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = src.find("Gamma").unwrap();
st.update_cursor_block();
st.cursor_block_entered_at = None;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(15, 4), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
let line_start = src.find("- Gamma").unwrap();
assert_eq!(
st.cursor.offset,
line_start + 15,
"expected cursor on the 't' of 'tail', landed on {:?}",
st.contents().chars().nth(st.cursor.offset),
);
}
#[test]
fn click_on_code_block_body_lands_on_clicked_char() {
let src = "```rust\nlet x = 1;\n```\n";
let line_start = src.find("let").unwrap();
for (screen_col, raw_col) in [(1u16, 0usize), (5, 4), (7, 6), (10, 9)] {
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(screen_col, 1), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.cursor.offset,
line_start + raw_col,
"click at screen col {screen_col} should land on {:?}, landed on {:?}",
"let x = 1;".chars().nth(raw_col),
st.contents().chars().nth(st.cursor.offset),
);
}
}
#[test]
fn click_on_code_nested_in_list_item_lands_on_clicked_line() {
let src = "8. Tag it.\n\n ```bash\n git tag\n gh run watch\n ```\n";
for (row, expected) in [
(0u16, "8. Tag it."),
(1, ""),
(2, " ```bash"),
(3, " git tag"),
(4, " gh run watch"),
] {
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(3, row), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
let line = st.buffer.rope().char_to_line(st.cursor.offset);
assert_eq!(
st.buffer.line(line).unwrap().trim_end_matches('\n'),
expected,
"click on row {row}",
);
}
}
#[test]
fn click_on_a_nested_items_continuation_lands_on_clicked_char() {
let src = "- a\n - b\n soft word\n";
for (screen_col, target) in [(6u16, "soft word"), (11, "word"), (2, "soft word")] {
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(screen_col, 2), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.cursor.offset,
src.find(target).unwrap(),
"click at col {screen_col} should land on {target:?}, landed on {:?}",
st.contents().chars().nth(st.cursor.offset),
);
}
}
#[test]
fn click_on_code_nested_in_list_item_lands_on_clicked_char() {
let src = "8. Tag it.\n\n ```bash\n gh run watch\n\n indented\n ```\n";
for (screen_col, row, target) in [
(4u16, 3u16, "gh run watch"),
(7, 3, "run watch"),
(11, 3, "watch"),
(6, 5, "indented"),
] {
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(screen_col, row), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.cursor.offset,
src.find(target).unwrap(),
"click at ({screen_col}, {row}) should land on {target:?}, landed on {:?}",
st.contents().chars().nth(st.cursor.offset),
);
}
}
#[test]
fn click_below_a_fence_on_a_list_marker_line_lands_on_clicked_line() {
let src = "- ```bash\n gh run watch\n ```\n- next item\n";
for (screen_col, row, target) in [
(0u16, 1u16, "- ```bash"),
(6, 2, "run watch"),
(2, 4, "next item"),
] {
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(screen_col, row), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
let line = st.buffer.rope().char_to_line(st.cursor.offset);
let target_line = st.buffer.rope().char_to_line(src.find(target).unwrap());
assert_eq!(
line, target_line,
"click at ({screen_col}, {row}) landed on the wrong line"
);
if row != 1 {
assert_eq!(
st.cursor.offset,
src.find(target).unwrap(),
"click at ({screen_col}, {row}) should land on {target:?}",
);
}
}
}
#[test]
fn click_on_the_extra_row_of_a_last_list_item_lands_on_its_line() {
for src in ["- a\n- > q\n\nafter\n", "- a\n- - b\n\nafter\n"] {
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(4, 2), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.buffer.rope().char_to_line(st.cursor.offset),
1,
"{src:?}: a click on row 2 must land on the second item's line"
);
}
}
#[test]
fn click_on_indented_code_block_body_lands_on_clicked_char() {
let src = "Intro.\n\n let x = 1;\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(7, 2), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.cursor.offset,
src.find('=').unwrap(),
"expected the '=', landed on {:?}",
st.contents().chars().nth(st.cursor.offset),
);
}
#[test]
fn click_past_end_of_code_line_clamps_to_code_line_end() {
let src = "```rust\nlet x = 1;\n```\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(60, 1), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.cursor.offset,
src.find("let").unwrap() + "let x = 1;".len(),
"expected end of the code line, landed on {:?}",
st.contents().chars().nth(st.cursor.offset),
);
}
#[test]
fn click_on_unclosed_code_block_last_line_lands_on_clicked_char() {
let src = "```rust\nlet x = 1;\n";
let line_start = src.find("let").unwrap();
for (screen_col, raw_col) in [(1u16, 0usize), (5, 4), (7, 6)] {
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(screen_col, 1), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.cursor.offset,
line_start + raw_col,
"click at screen col {screen_col} should land on {:?}, landed on {:?}",
"let x = 1;".chars().nth(raw_col),
st.contents().chars().nth(st.cursor.offset),
);
}
}
#[test]
fn click_past_rendered_end_of_link_line_lands_at_raw_end_of_line() {
let src = "[File link](./plan.md)\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(50, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.cursor.offset, 22,
"expected cursor at end of raw line (22), got {}",
st.cursor.offset,
);
}
#[test]
fn ctrl_click_in_rendered_on_link_follows_without_moving_cursor() {
let mut st = state("See [docs](https://example.com) for more.\n");
st.mode = Mode::Rendered;
let cursor_before = st.cursor.offset;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(ctrl_click_event(5, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
let pending = st
.pending_link_follow
.take()
.expect("ctrl-click sets target");
assert!(matches!(&pending, LinkTarget::Url(u) if u == "https://example.com"));
assert_eq!(st.cursor.offset, cursor_before);
}
#[test]
fn raw_reveal_fallback_detects_link_in_bracket_syntax() {
let src = "See [docs](https://example.com) for more.\n";
let mut st = state(src);
st.mode = Mode::Raw;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(ctrl_click_event(5, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
let pending = st
.pending_link_follow
.take()
.expect("raw-mode click follows link");
assert!(matches!(&pending, LinkTarget::Url(u) if u == "https://example.com"));
}
#[test]
fn click_on_non_link_text_does_not_set_pending_follow() {
let mut st = state("A paragraph with no links.\n");
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(4, 0), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert!(st.pending_link_follow.is_none());
}
#[test]
fn hovered_link_url_returns_raw_url_on_link_hover() {
let st = state("See [docs](https://example.com) for more.\n");
let url = mouse_ops::hovered_link_url(&st, 5, 0, VW);
assert_eq!(url.as_deref(), Some("https://example.com"));
}
#[test]
fn hovered_link_url_keeps_relative_path_as_written() {
let st = state("See [notes](./notes.md) for more.\n");
let url = mouse_ops::hovered_link_url(&st, 5, 0, VW);
assert_eq!(url.as_deref(), Some("./notes.md"));
}
#[test]
fn hovered_link_url_none_outside_link_span() {
let st = state("See [docs](https://example.com) for more.\n");
assert!(mouse_ops::hovered_link_url(&st, 0, 0, VW).is_none());
}
#[test]
fn each_item_of_a_link_list_resolves_to_its_own_url() {
let st = state(concat!(
"# Manual\n\n",
"- [Getting started](getting-started.md)\n",
"- [Editing](editing.md)\n",
"- [Keybindings](keybindings.md)\n",
));
assert_eq!(
mouse_ops::hovered_link_url(&st, 4, 3, VW).as_deref(),
Some("getting-started.md")
);
assert_eq!(
mouse_ops::hovered_link_url(&st, 4, 4, VW).as_deref(),
Some("editing.md")
);
assert_eq!(
mouse_ops::hovered_link_url(&st, 4, 5, VW).as_deref(),
Some("keybindings.md")
);
}
#[test]
fn adjacent_links_joined_by_a_vanished_break_resolve_to_their_own_urls() {
let mut st = state("x\n\n[日本](a.md)\n[語](b.md) [c](c.md)\n");
st.mode = edamame::editor::Mode::Rendered;
st.set_reflow(true);
st.sync_reflow_for_mode();
for (col, url) in [(0, "a.md"), (4, "b.md"), (7, "c.md")] {
assert_eq!(
mouse_ops::hovered_link_url(&st, col, 2, VW).as_deref(),
Some(url),
"cell {col}"
);
}
}
#[test]
fn an_underlined_heading_is_not_a_link() {
let st = state("## Documentation\n\n- [Getting started](getting-started.md)\n");
for col in 0..16u16 {
assert_eq!(
mouse_ops::hovered_link_url(&st, col, 0, VW),
None,
"col {col} of the heading reported a link"
);
}
assert!(!mouse_ops::hit_test_clickable(&st, 4, 0, VW, &[]));
assert_eq!(
mouse_ops::hovered_link_url(&st, 4, 2, VW).as_deref(),
Some("getting-started.md")
);
}
#[test]
fn a_link_inside_a_heading_covers_only_its_own_text() {
let st = state("## See [Docs](x.md) now\n");
assert_eq!(mouse_ops::hovered_link_url(&st, 3, 0, VW), None);
assert_eq!(
mouse_ops::hovered_link_url(&st, 6, 0, VW).as_deref(),
Some("x.md")
);
assert_eq!(
mouse_ops::hovered_link_url(&st, 9, 0, VW).as_deref(),
Some("x.md")
);
assert_eq!(mouse_ops::hovered_link_url(&st, 11, 0, VW), None);
}
#[test]
fn a_wrapped_paragraph_above_does_not_shift_the_lookup() {
let long = "word ".repeat(40);
let st = state(&format!("{long}\n\n[Docs](x.md)\n"));
let width = 20usize;
let hit = (0..40u16)
.find_map(|row| mouse_ops::hovered_link_url(&st, 1, row, width).map(|url| (row, url)));
let (row, url) = hit.expect("the link resolves on some row");
assert_eq!(url, "x.md");
for above in 0..row {
assert_eq!(mouse_ops::hovered_link_url(&st, 1, above, width), None);
}
}
#[test]
fn the_space_after_a_trailing_link_is_not_the_link() {
let st = state("See [docs](https://example.com)\n");
assert_eq!(
mouse_ops::hovered_link_url(&st, 5, 0, VW).as_deref(),
Some("https://example.com")
);
assert_eq!(mouse_ops::hovered_link_url(&st, 40, 0, VW), None);
assert!(!mouse_ops::hit_test_clickable(&st, 40, 0, VW, &[]));
}
#[test]
fn the_non_link_hit_test_declines_a_link_and_the_pair_adds_back_up() {
let st = state("See [docs](https://example.com) and - [ ] a task\n");
for col in 0..48u16 {
let whole = mouse_ops::hit_test_clickable(&st, col, 0, VW, &[]);
let composed = mouse_ops::hit_test_clickable_non_link(&st, col, 0, VW, &[])
|| mouse_ops::hovered_link_url(&st, col, 0, VW).is_some();
assert_eq!(whole, composed, "col {col} disagreed");
}
assert!(mouse_ops::hit_test_clickable(&st, 5, 0, VW, &[]));
assert!(!mouse_ops::hit_test_clickable_non_link(&st, 5, 0, VW, &[]));
}
#[test]
fn an_in_document_heading_anchor_is_a_link() {
let st = state("# Alpha\n\nSee [Alpha](#alpha) here\n");
let hit = (0..8u16)
.find_map(|row| mouse_ops::hovered_link_url(&st, 6, row, VW).map(|url| (row, url)));
let (row, url) = hit.expect("the anchor resolves on some row");
assert_eq!(url, "#alpha");
assert!(mouse_ops::hit_test_clickable(&st, 6, row, VW, &[]));
assert_eq!(mouse_ops::hovered_link_url(&st, 0, row, VW), None);
}
#[test]
fn an_inline_image_is_not_a_link_and_does_not_shift_the_one_after_it() {
let st = state(" see [docs](d.md)\n");
for col in [0u16, 3, 8, 12] {
assert_eq!(
mouse_ops::hovered_link_url(&st, col, 0, VW),
None,
"col {col} of the image placeholder reported a link"
);
assert!(!mouse_ops::hit_test_clickable(&st, col, 0, VW, &[]));
}
for col in 18..=21u16 {
assert_eq!(
mouse_ops::hovered_link_url(&st, col, 0, VW).as_deref(),
Some("d.md"),
"col {col} of the link did not resolve"
);
}
}
#[test]
fn an_inline_image_does_not_shift_later_lines_of_the_block() {
let st = state(concat!(
"-  [one](one.md)\n",
"- [two](two.md)\n",
"- [three](three.md)\n",
));
assert_eq!(
mouse_ops::hovered_link_url(&st, 14, 0, VW).as_deref(),
Some("one.md")
);
assert_eq!(
mouse_ops::hovered_link_url(&st, 3, 1, VW).as_deref(),
Some("two.md")
);
assert_eq!(
mouse_ops::hovered_link_url(&st, 3, 2, VW).as_deref(),
Some("three.md")
);
}
#[test]
fn an_image_wrapped_in_a_link_still_follows_the_link() {
let st = state("[](https://example.com) and [docs](d.md)\n");
assert_eq!(
mouse_ops::hovered_link_url(&st, 3, 0, VW).as_deref(),
Some("https://example.com")
);
let docs = (0..VW as u16)
.find_map(|col| mouse_ops::hovered_link_url(&st, col, 0, VW).filter(|u| u == "d.md"));
assert_eq!(docs.as_deref(), Some("d.md"));
}
#[test]
fn link_target_parse_classifies_inputs_correctly() {
use std::path::PathBuf;
assert_eq!(
LinkTarget::parse("#section", None),
LinkTarget::Anchor("section".to_owned())
);
assert_eq!(
LinkTarget::parse("https://example.com", None),
LinkTarget::Url("https://example.com".to_owned())
);
assert_eq!(
LinkTarget::parse("mailto:a@b.c", None),
LinkTarget::Url("mailto:a@b.c".to_owned())
);
let base = PathBuf::from("/docs");
assert_eq!(
LinkTarget::parse("sibling.md", Some(&base)),
LinkTarget::LocalFile {
path: base.join("sibling.md"),
fragment: None,
}
);
assert_eq!(
LinkTarget::parse("sibling.md#a-section", Some(&base)),
LinkTarget::LocalFile {
path: base.join("sibling.md"),
fragment: Some("a-section".to_owned()),
}
);
}
#[test]
fn column_border_drag_release_defers_commit_when_no_existing_comment() {
let src = "| abc | defghi |\n| --- | --- |\n| bar | baz |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = fake_snapshot(FakeSnapshotSpec {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: 2,
row_count: 3,
col_ranges: vec![1..6, 7..15],
row_ranges: vec![3..4],
row_handle_col: None,
top_border_row: None,
});
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(6, 3), &mut target, &snapshots, VP, VW);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 8, row: 3 },
&mut target,
&snapshots,
VP,
VW,
);
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
assert!(st.has_pending_column_widths());
assert_eq!(st.contents(), src);
st.cancel_pending_column_widths();
assert!(!st.has_pending_column_widths());
assert!(
st.live_table_widths.is_none(),
"Cancel must clear the live preview"
);
assert!(
!st.contents().contains("tui-columns"),
"Cancel must NOT inject a comment, got: {:?}",
st.contents()
);
}
#[test]
fn table_has_tui_columns_comment_detects_existing_persistence() {
let with = state("| a | b |\n|---|---|\n| 1 | 2 |\n<!-- tui-columns: [5, 7] -->\n");
assert!(with.table_has_tui_columns_comment(0));
let without = state("| a | b |\n|---|---|\n| 1 | 2 |\n");
assert!(!without.table_has_tui_columns_comment(0));
}
fn snapshot_with_delete_handles(
src: &str,
col_ranges: Vec<std::ops::Range<u16>>,
row_ranges: Vec<std::ops::Range<u16>>,
delete_row_handle_col: Option<u16>,
bottom_border_row: Option<u16>,
) -> TableLayoutSnapshot {
TableLayoutSnapshot {
table_byte_start: 0,
table_byte_end: src.len(),
col_count: col_ranges.len(),
row_count: 2 + row_ranges.len(),
col_ranges,
row_ranges,
row_handle_col: None,
top_border_row: None,
header_row: None,
delete_row_handle_col,
bottom_border_row,
}
}
#[test]
fn delete_row_handle_click_removes_data_row() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = snapshot_with_delete_handles(src, vec![2..5, 6..9], vec![3..4, 5..6], Some(9), None);
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(9, 3), &mut target, &snapshots, VP, VW);
let after = st.contents();
assert!(
!after.contains("| 1 | 2 |"),
"expected first data row deleted, got: {after:?}"
);
assert!(
after.contains("| 3 | 4 |"),
"expected second data row preserved, got: {after:?}"
);
assert!(target.is_none());
assert!(!st.drag_in_progress);
}
#[test]
fn delete_column_handle_click_removes_column() {
let src = "| a | b | c |\n|---|---|---|\n| 1 | 2 | 3 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap =
snapshot_with_delete_handles(src, vec![2..5, 6..9, 10..13], vec![3..4], None, Some(4));
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(7, 4), &mut target, &snapshots, VP, VW);
let after = st.contents();
assert!(
!after.contains(" b "),
"expected column b deleted, got: {after:?}"
);
assert!(
!after.contains(" 2 "),
"expected data 2 deleted, got: {after:?}"
);
assert!(after.contains(" a "), "got: {after:?}");
assert!(after.contains(" c "), "got: {after:?}");
assert!(target.is_none());
assert!(!st.drag_in_progress);
}
#[test]
fn right_column_resize_still_works_via_cell_just_inside_border() {
let src = "| headerA | headerB |\n|-----|-----|\n| foo | bar |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap =
snapshot_with_delete_handles(src, vec![2..12, 13..23], vec![3..4], Some(23), Some(5));
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(22, 3), &mut target, &snapshots, VP, VW);
assert!(
matches!(
target,
Some(mouse_ops::DragTarget::TableColumnBorder { .. })
),
"expected ColumnBorder drag at border-1, got: {target:?}",
);
assert_eq!(st.contents(), src);
}
#[test]
fn right_border_on_non_data_row_still_resizes() {
let src = "| headerA | headerB |\n|-----|-----|\n| foo | bar |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap =
snapshot_with_delete_handles(src, vec![2..12, 13..23], vec![3..4], Some(23), Some(5));
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(23, 1), &mut target, &snapshots, VP, VW);
assert!(
matches!(
target,
Some(mouse_ops::DragTarget::TableColumnBorder { .. })
),
"expected ColumnBorder drag on non-data row, got: {target:?}",
);
assert_eq!(st.contents(), src);
}
#[test]
fn right_border_on_data_row_deletes() {
let src = "| headerA | headerB |\n|-----|-----|\n| foo | bar |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap =
snapshot_with_delete_handles(src, vec![2..12, 13..23], vec![3..4], Some(23), Some(5));
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(23, 3), &mut target, &snapshots, VP, VW);
let after = st.contents();
assert!(
!after.contains("foo"),
"expected data row deleted, got: {after:?}"
);
assert!(target.is_none());
assert!(!st.drag_in_progress);
}
#[test]
fn delete_handles_inert_when_handle_fields_are_none() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
let snap = snapshot_with_delete_handles(src, vec![2..5, 6..9], vec![3..4], None, None);
let snapshots = [snap];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(9, 3), &mut target, &snapshots, VP, VW);
assert!(
matches!(
target,
Some(mouse_ops::DragTarget::TableColumnBorder { .. })
),
"expected ColumnBorder drag with handles disabled, got: {target:?}",
);
assert_eq!(st.contents(), src);
target = None;
mouse_ops::apply(&mut st, click(7, 4), &mut target, &snapshots, VP, VW);
assert_eq!(st.contents(), src);
}
#[test]
fn click_on_wrapped_continuation_row_lands_in_continuation_text() {
let mut st = state("the quick brown fox jumps over\n");
st.mode = Mode::Rendered;
let viewport_w: usize = 16;
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
let click_area = Rect {
x: 0,
y: 0,
width: viewport_w as u16,
height: 24,
};
let action = mouse
.dispatch(click_event(0, 1), click_area)
.expect("click dispatched");
mouse_ops::apply(&mut st, action, &mut anchor, &[], VP, viewport_w);
assert_eq!(st.cursor.offset, 16);
}
#[test]
fn click_on_wrapped_row_handles_wide_char_in_row() {
let mut st = state("🥇 leader plus more text after\n");
st.mode = Mode::Rendered;
let viewport_w: usize = 12;
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
let click_area = Rect {
x: 0,
y: 0,
width: viewport_w as u16,
height: 24,
};
let action = mouse
.dispatch(click_event(5, 2), click_area)
.expect("click dispatched");
mouse_ops::apply(&mut st, action, &mut anchor, &[], VP, viewport_w);
assert_eq!(st.contents().chars().nth(st.cursor.offset), Some('a'));
}
#[test]
fn same_mermaid_block_click_does_not_set_drag_in_progress() {
let src = "```mermaid\nflowchart TD\nA-->B\nB-->C\n```\n";
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = src.find("flowchart").unwrap();
st.update_cursor_block();
st.cursor_block_entered_at = None;
st.latch_cursor_reveal();
assert!(st.cursor_block_revealed() && st.cursor_reveal_latched);
let original_block = st.cursor_block_idx;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(0, 2), &mut anchor, &[], VP, VW);
assert_eq!(
st.cursor_block_idx, original_block,
"click stayed inside the same mermaid block",
);
assert!(
!st.drag_in_progress,
"intra-mermaid click must not flip drag-in-progress on",
);
assert!(
st.cursor_block_revealed(),
"intra-mermaid click must keep raw reveal active",
);
}
#[test]
fn a_drag_within_a_revealed_mermaid_block_keeps_it_revealed() {
let src = "```mermaid\nflowchart TD\nA-->B\nB-->C\n```\n";
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = src.find("flowchart").unwrap();
st.update_cursor_block();
st.cursor_block_entered_at = None;
st.latch_cursor_reveal();
assert!(st.cursor_block_revealed() && st.cursor_reveal_latched);
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(0, 2), &mut anchor, &[], VP, VW);
mouse_ops::apply(
&mut st,
MouseAction::Drag { col: 3, row: 3 },
&mut anchor,
&[],
VP,
VW,
);
assert!(st.selection.is_some_and(|s| s.anchor != s.active));
assert!(!st.drag_in_progress);
assert!(
st.cursor_block_revealed() && st.cursor_reveal_latched,
"a drag inside the diagram must not flash its image back in",
);
mouse_ops::apply(&mut st, MouseAction::Release, &mut anchor, &[], VP, VW);
assert!(
st.cursor_block_revealed(),
"the latch holds the reveal past mouse-up"
);
}
#[test]
fn click_on_visible_mermaid_block_parks_cursor_at_end_of_last_code_line() {
let src = "Intro.\n\n```mermaid\nflowchart TD\nA-->B\nB-->C\n```\n\nTrailer.\n";
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = src.find("Trailer").unwrap();
st.update_cursor_block();
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(5, 3), &mut anchor, &[], VP, VW);
let chars: Vec<char> = st.contents().chars().collect();
let last_c = src.find("B-->C").unwrap() + "B-->C".len();
assert_eq!(
st.cursor.offset,
last_c,
"click on rendered mermaid placeholder must park cursor after last code char (got {:?})",
chars.get(st.cursor.offset),
);
assert!(
st.cursor_block_revealed(),
"image click must force-reveal the block immediately",
);
assert!(anchor.is_none(), "image click should not start a drag");
}
#[test]
fn click_on_visible_image_block_parks_cursor_at_end_of_source_line() {
let src = "Before.\n\n\n\nAfter.\n";
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = src.find("Before").unwrap();
st.update_cursor_block();
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(3, 2), &mut anchor, &[], VP, VW);
let line_end = src.find("").unwrap() + "".len();
assert_eq!(
st.cursor.offset, line_end,
"click on rendered image must park cursor at end of source line",
);
assert!(
st.cursor_block_revealed(),
"image click must force-reveal the block immediately",
);
}
#[test]
fn click_on_mermaid_row_lands_on_clicked_column() {
let src = "```mermaid\nflowchart TD\nA-->B\n```\n";
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = src.find("flowchart").unwrap();
st.update_cursor_block();
st.cursor_block_entered_at = None;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(2, 2), &mut anchor, &[], VP, VW);
let chars: Vec<char> = st.contents().chars().collect();
assert_eq!(
chars.get(st.cursor.offset),
Some(&'-'),
"click on col 2 of 'A-->B' must land on third char, landed at {} = {:?}",
st.cursor.offset,
chars.get(st.cursor.offset),
);
}
#[test]
fn click_on_wrapped_mermaid_line_lands_on_continuation() {
let long = "A".repeat(100); let src = format!("```mermaid\n{long}\nB-->C\n```\n");
let mut st = state(&src);
st.mode = Mode::Rendered;
st.cursor.offset = src.find(&long[..]).unwrap();
st.update_cursor_block();
st.cursor_block_entered_at = None;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(5, 2), &mut anchor, &[], VP, VW);
let long_start = src.find(&long[..]).unwrap();
let target = long_start + 85;
assert_eq!(
st.cursor.offset, target,
"click on wrap continuation at col 5 must land at char 85 of the long mermaid line",
);
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(0, 3), &mut anchor, &[], VP, VW);
let b_start = src.find("B-->C").unwrap();
assert_eq!(
st.cursor.offset, b_start,
"click on row 3 col 0 must land at start of 'B-->C', not somewhere on the long line",
);
}
#[test]
fn intra_mermaid_line_move_keeps_the_reveal_latched() {
let src = "Intro paragraph.\n\n```mermaid\nflowchart TD\nA-->B\nB-->C\n```\n\nTrailer.\n";
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = 0;
st.update_cursor_block();
st.cursor.offset = src.find("flowchart").unwrap();
st.update_cursor_block();
assert!(
st.cursor_block_entered_at.is_some() && !st.cursor_reveal_latched,
"entering mermaid arms the reveal timer and starts unrevealed",
);
st.cursor_block_entered_at = None;
st.latch_cursor_reveal();
assert!(
st.cursor_reveal_latched,
"a dwell latches the mermaid reveal"
);
st.cursor.offset = src.find("A-->B").unwrap();
st.update_cursor_block();
assert!(
st.cursor_block_entered_at.is_some(),
"an intra-mermaid line move re-arms the reveal timer, like every other block",
);
assert!(
st.cursor_block_revealed(),
"the latch holds the mermaid block revealed across the move",
);
st.cursor.offset = 0;
st.update_cursor_block();
assert!(
!st.cursor_reveal_latched && st.cursor_block_entered_at.is_some(),
"leaving the mermaid block drops the latch and re-arms the reveal timer",
);
}
#[test]
fn click_on_reserved_image_row_does_not_poison_inline_map_cache() {
use edamame::document::Selection;
use edamame::ui::{RenderedView, RenderedViewState};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let theme = theme();
let long = "Paragraph with a very long line that keeps going on and on past wrap width here.";
let src = format!("\n\n{long}\n");
let mut st = EditorState::new(Buffer::from_str(&src), theme);
st.mode = Mode::Rendered;
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
if let Some(a) = mouse.dispatch(click_event(2, 2), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
let total = st.buffer.len_chars();
st.selection = Some(Selection {
anchor: 0,
active: total,
});
st.cursor.offset = 0;
let backend = TestBackend::new(80, 40);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &st,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
}
const CELL_TABLE: &str = "| a | b | c |\n|---|---|---|\n| alpha | bravo | charlie |\n";
const LONG_CELL: &str = "the quick brown fox jumps over the lazy dog and keeps running through the quiet forest until dusk";
fn wrapped_table() -> String {
format!("| k | description |\n|---|---|\n| x | {LONG_CELL} |\n")
}
fn drag(col: u16, row: u16) -> MouseAction {
MouseAction::Drag { col, row }
}
fn triple_click_at(st: &mut EditorState, col: u16, row: u16) {
let mut mouse = MouseDispatcher::new();
let mut anchor: Option<mouse_ops::DragTarget> = None;
for _ in 0..3 {
if let Some(a) = mouse.dispatch(click_event(col, row), area()) {
mouse_ops::apply(st, a, &mut anchor, &[], VP, VW);
}
if let Some(a) = mouse.dispatch(up_event(col, row), area()) {
mouse_ops::apply(st, a, &mut anchor, &[], VP, VW);
}
}
}
#[test]
fn rendered_drag_from_cell_clamps_to_cell() {
let mut st = state(CELL_TABLE);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(11, 3), &mut anchor, &[], VP, VW);
assert_eq!(st.cursor.offset, 39, "anchor on the 'r' of bravo");
mouse_ops::apply(&mut st, drag(22, 3), &mut anchor, &[], VP, VW);
let sel = st.selection.expect("drag sets selection");
let (s, e) = sel.range();
assert_eq!(
st.buffer.slice_to_string(s, e),
"ravo ",
"selection must stop at bravo's cell boundary"
);
mouse_ops::apply(&mut st, drag(2, 1), &mut anchor, &[], VP, VW);
let sel = st.selection.expect("selection persists");
let (s, e) = sel.range();
assert_eq!(
st.buffer.slice_to_string(s, e),
" b",
"upward drag must clamp to the cell's content start"
);
mouse_ops::apply(&mut st, drag(5, 10), &mut anchor, &[], VP, VW);
let sel = st.selection.expect("selection persists");
let (s, e) = sel.range();
assert_eq!(
st.buffer.slice_to_string(s, e),
"ravo ",
"drag off the table must clamp to the cell's content end"
);
}
#[test]
fn rendered_drag_across_wrapped_cell_sub_rows() {
let src = wrapped_table();
let mut st = state(&src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let cell_start = src.find(LONG_CELL).expect("cell text in source");
let chunk1 = LONG_CELL.find("the quiet").expect("wrap point");
mouse_ops::apply(&mut st, click(10, 3), &mut anchor, &[], VP, VW);
assert_eq!(
st.cursor.offset,
cell_start + 2,
"click lands on the first chunk's char 2"
);
mouse_ops::apply(&mut st, drag(10, 4), &mut anchor, &[], VP, VW);
let sel = st.selection.expect("drag sets selection");
let (s, e) = sel.range();
assert_eq!(
st.buffer.slice_to_string(s, e),
&LONG_CELL[2..chunk1 + 2],
"drag onto the wrapped sub-row must map into the second chunk"
);
mouse_ops::apply(&mut st, drag(79, 4), &mut anchor, &[], VP, VW);
let sel = st.selection.expect("selection persists");
let (s, e) = sel.range();
let text = st.buffer.slice_to_string(s, e);
assert!(
!text.contains('|') && !text.contains('\n'),
"selection escaped the cell: {text:?}"
);
}
#[test]
fn preview_triple_click_selects_whole_wrapped_cell() {
let src = wrapped_table();
let mut st = state(&src);
assert_eq!(st.mode, Mode::Preview);
triple_click_at(&mut st, 20, 3);
let vs = st.visual_selection.expect("triple-click sets selection");
let band = vs.band.expect("cell band recorded");
assert_eq!(band.lines, (3, 4), "band covers both wrapped sub-rows");
assert_eq!(band.cols, (8, 78), "band covers the cell's content area");
assert_eq!(vs.anchor, (3, 8));
assert_eq!(
vs.active,
(4, 8 + "the quiet forest until dusk".chars().count())
);
let copied = mouse_ops::visual_selection_to_rendered_text(vs, &st.parsed.lines);
assert_eq!(
copied, LONG_CELL,
"copy must reconstruct the full cell text without borders or padding"
);
}
#[test]
fn preview_triple_click_outside_table_selects_line() {
let mut st = state("first line\nsecond line\n");
assert_eq!(st.mode, Mode::Preview);
triple_click_at(&mut st, 3, 1);
let vs = st.visual_selection.expect("triple-click sets selection");
assert_eq!(vs.band, None, "non-table lines select without a band");
assert_eq!(vs.anchor, (1, 0));
assert_eq!(vs.active, (1, "second line".len()));
}
#[test]
fn preview_triple_click_on_table_border_selects_line() {
let mut st = state(CELL_TABLE);
triple_click_at(&mut st, 3, 0); let vs = st.visual_selection.expect("triple-click sets selection");
assert_eq!(vs.band, None, "border rows keep full-line selection");
assert_eq!(vs.anchor.0, 0);
}
#[test]
fn preview_drag_from_cell_constrained_to_band() {
let src = wrapped_table();
let mut st = state(&src);
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(10, 3), &mut anchor, &[], VP, VW);
let vs = st.visual_selection.expect("click seeds selection");
let band = vs.band.expect("click in cell records band");
assert_eq!(band.lines, (3, 4));
assert_eq!(band.cols, (8, 78));
mouse_ops::apply(&mut st, drag(2, 5), &mut anchor, &[], VP, VW);
let vs = st.visual_selection.expect("selection persists");
assert_eq!(vs.active, (4, 8), "drag clamps to the band's corner");
mouse_ops::apply(&mut st, drag(1, 1), &mut anchor, &[], VP, VW);
let vs = st.visual_selection.expect("selection persists");
assert_eq!(vs.active, (3, 8));
}
#[test]
fn preview_drag_in_first_cell_does_not_leak_into_neighbor() {
let mut st = state(CELL_TABLE);
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(3, 3), &mut anchor, &[], VP, VW);
mouse_ops::apply(&mut st, drag(13, 3), &mut anchor, &[], VP, VW);
let vs = st.visual_selection.expect("selection persists");
let copied = mouse_ops::visual_selection_to_rendered_text(vs, &st.parsed.lines);
assert!(
copied.starts_with("lpha") && !copied.contains("bravo") && !copied.contains('│'),
"selection leaked out of cell 0: {copied:?}"
);
}
#[test]
fn preview_triple_click_on_cell_with_escaped_pipe() {
let mut st = state("| a | b |\n|---|---|\n| x\\|y | z |\n");
triple_click_at(&mut st, 3, 3);
let vs = st.visual_selection.expect("triple-click sets selection");
assert!(vs.band.is_some(), "escaped-pipe cell still maps to a band");
let copied = mouse_ops::visual_selection_to_rendered_text(vs, &st.parsed.lines);
assert_eq!(
copied, "x|y",
"escaped pipe renders literally inside the cell"
);
}
#[test]
fn preview_triple_click_in_empty_cell_copies_nothing() {
let mut st = state("| head | b |\n|---|---|\n| | z |\n");
triple_click_at(&mut st, 3, 3);
if let Some(vs) = st.visual_selection {
let copied = mouse_ops::visual_selection_to_rendered_text(vs, &st.parsed.lines);
assert_eq!(copied.trim(), "", "empty cell must copy as empty");
}
}
#[test]
fn preview_triple_click_in_header_cell_selects_header_content() {
let mut st = state(CELL_TABLE);
triple_click_at(&mut st, 2, 1); let vs = st.visual_selection.expect("triple-click sets selection");
assert!(vs.band.is_some(), "header cells band like data cells");
let copied = mouse_ops::visual_selection_to_rendered_text(vs, &st.parsed.lines);
assert_eq!(copied, "a");
}
#[test]
fn click_on_ordered_list_content_lands_on_clicked_char() {
let doc: String = (1..=10).map(|i| format!("{i}. item\n")).collect();
let mut st = state(&doc);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(4, 2), &mut anchor, &[], VP, VW);
assert_eq!(
st.cursor.offset, 19,
"click on 'i' of ' 3. item' must land on raw col 3 of its line"
);
}
#[test]
fn click_on_nested_list_content_accounts_for_indent_difference() {
let mut st = state("- top\n - inner\n");
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(6, 1), &mut anchor, &[], VP, VW);
assert_eq!(
st.cursor.offset, 10,
"click on 'i' of the nested item must land on its raw position"
);
}
#[test]
fn click_on_nested_task_checkbox_toggles_it() {
let mut st = state("- top\n - [ ] sub\n");
st.mode = Mode::Rendered;
st.cursor.offset = 0;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(8, 1), &mut anchor, &[], VP, VW);
assert!(
st.contents().contains(" - [x] sub"),
"click on the nested checkbox must toggle it, got: {:?}",
st.contents()
);
assert_eq!(
st.cursor.offset, 0,
"checkbox toggle must not move the cursor"
);
}
#[test]
fn click_on_bold_text_inside_ordered_item_uses_inline_map() {
let mut st = state("para\n\n1. **bold** tail\n");
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(8, 2), &mut anchor, &[], VP, VW);
assert_eq!(
st.cursor.offset, 18,
"click on 't' of 'tail' must skip the raw `**` markers"
);
}
#[test]
fn clicks_on_a_wrapped_revealed_list_item_land_under_the_pointer() {
use edamame::ui::{RenderedView, RenderedViewState};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
const W: u16 = 40;
const H: u16 = 12;
let src = "- Alpha item\n\n- Beta item\n\n- Gamma `code` tail that keeps \
going on and on until it wraps onto another visual row with \
more `code` here\n";
let build = || {
let mut st = state(src);
st.mode = Mode::Rendered;
st.viewport_width = W as usize;
st.cursor.offset = src.find("Gamma").unwrap();
st.update_cursor_block();
st.cursor_block_entered_at = None; st
};
let st = build();
let theme = theme();
let mut terminal = Terminal::new(TestBackend::new(W, H)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &st,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut checked = 0;
for row in 4..H {
for col in 0..W {
let painted = buf
.cell((col, row))
.and_then(|c| c.symbol().chars().next())
.unwrap_or(' ');
if painted == ' ' {
continue;
}
let mut st = build();
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(col, row), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, W as usize);
}
let landed = st.contents().chars().nth(st.cursor.offset);
assert_eq!(
landed,
Some(painted),
"click at (col {col}, row {row}) shows {painted:?} but landed on \
{landed:?} (offset {})",
st.cursor.offset,
);
checked += 1;
}
}
assert!(
checked > 60,
"expected a wrapped item to check, got {checked}"
);
}
#[test]
fn clicks_on_a_revealed_item_wider_than_the_viewport_land_under_the_pointer() {
use edamame::ui::{RenderedView, RenderedViewState};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
const W: u16 = 7;
const H: u16 = 12;
let src = "- [ ] abcdefghijklmnopqrstuvwxyz\n";
let build = || {
let mut st = state(src);
st.mode = Mode::Rendered;
st.viewport_width = W as usize;
st.cursor.offset = src.find("abc").unwrap();
st.update_cursor_block();
st.cursor_block_entered_at = None; st
};
let st = build();
let theme = theme();
let mut terminal = Terminal::new(TestBackend::new(W, H)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &st,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut checked = 0;
for row in 1..H {
for col in 0..W {
let painted = buf
.cell((col, row))
.and_then(|c| c.symbol().chars().next())
.unwrap_or(' ');
if painted == ' ' {
continue;
}
let mut st = build();
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(col, row), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, W as usize);
}
let landed = st.contents().chars().nth(st.cursor.offset);
assert_eq!(
landed,
Some(painted),
"click at (col {col}, row {row}) shows {painted:?} but landed on \
{landed:?} (offset {})",
st.cursor.offset,
);
checked += 1;
}
}
assert!(
checked > 20,
"expected several wrapped rows to check, got {checked}"
);
}
#[test]
fn click_inside_frontmatter_lands_on_the_clicked_char() {
let src = "---\ntitle: Foo\n\ntags: a\n---\n\nBody.\n";
let line_start = src.find("tags: a").unwrap();
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(6, 3), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.cursor.offset,
line_start + 6,
"expected the 'a' of `tags: a`, landed on {:?}",
st.contents().chars().nth(st.cursor.offset),
);
}
#[test]
fn click_on_a_yaml_sequence_entry_is_not_shifted_by_the_list_marker_map() {
let src = "---\ntags:\n - alpha\n---\n\nBody.\n";
let line_start = src.find(" - alpha").unwrap();
let mut st = state(src);
st.mode = Mode::Rendered;
let mut anchor: Option<mouse_ops::DragTarget> = None;
let mut mouse = MouseDispatcher::new();
if let Some(a) = mouse.dispatch(click_event(4, 2), area()) {
mouse_ops::apply(&mut st, a, &mut anchor, &[], VP, VW);
}
assert_eq!(
st.cursor.offset,
line_start + 4,
"expected the 'a' of `alpha`, landed on {:?}",
st.contents().chars().nth(st.cursor.offset),
);
}
#[test]
fn click_at_the_right_edge_of_an_absorbing_row_stays_on_that_row() {
let text = "一二三四五 klmnopqrst uvwxyzabcd";
let width = 10;
let rows = edamame::document::wrap::visual_rows_of_str(text, width);
assert_eq!(rows[0], (0, 5, 6), "fixture stopped absorbing its space");
let mut st = state(text);
st.mode = Mode::Raw;
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(9, 0), &mut anchor, &[], VP, width);
let (sub, _) = edamame::document::wrap::sub_line_of_col(&rows, st.cursor.offset);
assert_eq!(
sub, 0,
"click on row 0 placed the cursor on row {sub} (offset {})",
st.cursor.offset,
);
}
#[test]
fn preview_click_past_the_end_of_a_wrapped_row_stays_on_that_row() {
let text = "alpha bravo charlie delta";
let width = 12;
let rows = edamame::document::wrap::visual_rows_of_str(text, width);
assert_eq!(
rows[1],
(12, 20, 20),
"fixture stopped wrapping as expected"
);
let mut st = state(&format!("{text}\n"));
assert_eq!(st.mode, Mode::Preview);
let mut anchor: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(40, 1), &mut anchor, &[], VP, width);
let vs = st.visual_selection.expect("click sets a Preview selection");
assert_eq!(
vs.anchor, vs.active,
"a plain click is a collapsed selection"
);
let (line_idx, char_col) = vs.anchor;
assert_eq!(line_idx, 0);
let (sub, _) = edamame::document::wrap::sub_line_of_col(&rows, char_col);
assert_eq!(
sub, 1,
"click on row 1 placed the caret on row {sub} (char col {char_col})",
);
assert_eq!(char_col, 19, "caret should rest on row 1's last char");
}
const NESTED_TABLES: [(&str, &str); 4] = [
("- | a | b |\n |---|---|\n | 1 | 2 |\n | 3 | 4 |\n", " "),
("> | a | b |\n> |---|---|\n> | 1 | 2 |\n> | 3 | 4 |\n", "> "),
(
"x[^n]\n\n[^n]: | a | b |\n |---|---|\n | 1 | 2 |\n | 3 | 4 |\n",
" ",
),
(
"> - | a | b |\n> |---|---|\n> | 1 | 2 |\n> | 3 | 4 |\n",
"> ",
),
];
fn nested_table_state(src: &str) -> (EditorState, table_view::TableLayoutSnapshot) {
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = src.find("1 |").unwrap();
let snaps = table_view::build_snapshots(&st, Rect::new(0, 0, VW as u16, VP as u16), true);
assert_eq!(snaps.len(), 1, "one snapshot for {src:?}");
(st, snaps[0].clone())
}
fn gesture(
st: &mut EditorState,
snap: &table_view::TableLayoutSnapshot,
from: (u16, u16),
to: (u16, u16),
) {
let snapshots = [snap.clone()];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(st, click(from.0, from.1), &mut target, &snapshots, VP, VW);
mouse_ops::apply(st, drag(to.0, to.1), &mut target, &snapshots, VP, VW);
mouse_ops::apply(st, MouseAction::Release, &mut target, &snapshots, VP, VW);
}
#[test]
fn a_nested_table_gets_every_handle() {
for (src, _) in NESTED_TABLES {
let (_, snap) = nested_table_state(src);
let table_start = src.find("| a").unwrap();
let line_start = src[..table_start].rfind('\n').map_or(0, |i| i + 1);
assert_eq!(snap.table_byte_start, line_start, "{src:?}");
assert_eq!((snap.col_count, snap.row_count), (2, 4), "{src:?}");
assert_eq!(snap.row_ranges.len(), 2, "{src:?}");
assert!(snap.top_border_row.is_some(), "{src:?}");
assert!(snap.header_row.is_some(), "{src:?}");
assert!(snap.bottom_border_row.is_some(), "{src:?}");
let outer_left = snap.col_ranges[0].start - 1;
assert!(outer_left > 0, "the table sits behind its prefix: {src:?}");
assert_eq!(snap.row_handle_col, Some(outer_left), "{src:?}");
assert_eq!(
snap.delete_row_handle_col,
Some(snap.col_ranges[1].end),
"{src:?}"
);
assert_eq!(
snap.hit_test(snap.col_ranges[0].start + 1, snap.row_ranges[1].start),
Some(table_view::TableHit::Cell {
row_idx: 3,
col_idx: 0
}),
"{src:?}"
);
}
}
#[test]
fn handles_edit_a_nested_table_inside_its_container() {
let swap = |s: &str, a: &str, b: &str| s.replace(a, "\0").replace(b, a).replace('\0', b);
for (src, prefix) in NESTED_TABLES {
let (mut st, snap) = nested_table_state(src);
let x = snap.row_handle_col.unwrap();
let (y0, y1) = (snap.row_ranges[0].start, snap.row_ranges[1].start);
gesture(&mut st, &snap, (x, y0), (x, y1));
assert_eq!(
st.contents(),
swap(src, "| 1 | 2 |", "| 3 | 4 |"),
"row drag"
);
let (mut st, snap) = nested_table_state(src);
let y = snap.top_border_row.unwrap();
let mid = |r: &std::ops::Range<u16>| r.start + (r.end - r.start) / 2;
gesture(
&mut st,
&snap,
(mid(&snap.col_ranges[0]), y),
(mid(&snap.col_ranges[1]), y),
);
let want = swap(
&swap(src, "| a | b |", "| b | a |"),
"| 1 | 2 |",
"| 2 | 1 |",
);
let want = swap(&want, "| 3 | 4 |", "| 4 | 3 |");
assert_eq!(st.contents(), want, "column drag");
let (mut st, snap) = nested_table_state(src);
let snapshots = [snap.clone()];
let mut target: Option<mouse_ops::DragTarget> = None;
let (x, y) = (
snap.delete_row_handle_col.unwrap(),
snap.row_ranges[0].start,
);
mouse_ops::apply(&mut st, click(x, y), &mut target, &snapshots, VP, VW);
assert_eq!(
st.contents(),
src.replace(&format!("{prefix}| 1 | 2 |\n"), ""),
"row delete"
);
let (mut st, snap) = nested_table_state(src);
let (x, y) = (snap.col_ranges[0].end, snap.header_row.unwrap());
let snapshots = [snap.clone()];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(x, y), &mut target, &snapshots, VP, VW);
mouse_ops::apply(&mut st, drag(x + 3, y), &mut target, &snapshots, VP, VW);
let area = Rect::new(0, 0, VW as u16, VP as u16);
let widened = table_view::build_snapshots(&st, area, true)[0].col_ranges[0].clone();
assert_eq!(
widened.end - widened.start,
snap.col_ranges[0].end - snap.col_ranges[0].start + 3,
"the live preview widens a nested table: {src:?}"
);
mouse_ops::apply(
&mut st,
MouseAction::Release,
&mut target,
&snapshots,
VP,
VW,
);
st.commit_pending_column_widths();
let comment = st
.contents()
.lines()
.find(|l| l.contains("tui-columns"))
.map(str::to_owned);
assert!(
comment
.as_deref()
.is_some_and(|c| c.starts_with(&format!("{prefix}<!--"))),
"the widths comment stays in the container: {comment:?}"
);
let committed = table_view::build_snapshots(&st, area, true);
assert_eq!(committed.len(), 1, "the comment renders nothing: {src:?}");
assert_eq!(
committed[0].col_ranges[0], widened,
"the widths persist: {src:?}"
);
}
}
#[test]
fn two_tables_in_one_list_item_get_a_snapshot_each() {
let src = "- | a | b |\n |---|---|\n | 1 | 2 |\n\n | c | d |\n |---|---|\n | 3 | 4 |\n | 5 | 6 |\n";
let mut st = state(src);
st.mode = Mode::Rendered;
st.cursor.offset = src.find("3 |").unwrap();
let area = Rect::new(0, 0, VW as u16, VP as u16);
let snaps = table_view::build_snapshots(&st, area, true);
assert_eq!(snaps.len(), 2);
assert_eq!(snaps[0].table_byte_start, 0);
assert_eq!(snaps[1].table_byte_start, src.find(" | c").unwrap());
assert_eq!(snaps[0].row_ranges.len(), 1);
assert_eq!(snaps[1].row_ranges.len(), 2);
let snap = snaps[1].clone();
let (x, y) = (snap.col_ranges[0].end, snap.header_row.unwrap());
let snapshots = [snap.clone()];
let mut target: Option<mouse_ops::DragTarget> = None;
mouse_ops::apply(&mut st, click(x, y), &mut target, &snapshots, VP, VW);
mouse_ops::apply(&mut st, drag(x + 3, y), &mut target, &snapshots, VP, VW);
let live = table_view::build_snapshots(&st, area, true);
let width = |r: &std::ops::Range<u16>| r.end - r.start;
assert_eq!(
width(&live[1].col_ranges[0]),
width(&snap.col_ranges[0]) + 3,
"the second table widens"
);
assert_eq!(
live[0].col_ranges, snaps[0].col_ranges,
"the first table keeps its widths"
);
}
#[test]
fn a_striped_nested_table_splits_its_rows_at_the_stripes() {
for (src, _) in NESTED_TABLES {
let mut st = state(src);
st.mode = Mode::Rendered;
st.set_row_striping(true);
st.cursor.offset = src.find("1 |").unwrap();
let snaps = table_view::build_snapshots(&st, Rect::new(0, 0, VW as u16, VP as u16), true);
assert_eq!(snaps.len(), 1, "{src:?}");
let rows = &snaps[0].row_ranges;
assert_eq!(rows.len(), 2, "{src:?}");
assert_eq!(
rows[0].end + 1,
rows[1].start,
"one stripe between: {src:?}"
);
assert!(snaps[0].bottom_border_row.is_some(), "{src:?}");
}
}