mod checkbox;
mod coord;
mod footnotes;
mod links;
mod selection;
mod table_drag;
pub use footnotes::footnote_at_offset;
pub use links::{hovered_link_url, link_at_offset};
pub use selection::visual_selection_to_rendered_text;
use std::time::Duration;
use crate::document::{Selection, VisualSelection};
use crate::editor::list_edit;
use crate::editor::table_edit;
use crate::editor::{EditorState, Mode};
use crate::input::MouseAction;
use crate::ui::table_view::{TableHit, TableLayoutSnapshot};
use self::checkbox::toggle_checkbox_at;
use self::coord::{
click_to_char_offset, preview_table_cell_band, rendered_click_to_line_col,
rendered_line_at_row, table_cell_char_range_at,
};
use self::links::{follow_footnote_at_click, follow_link_at_click, link_at_rendered_pos};
use self::selection::{
expand_selection_to_inline_markers, select_line_at_cursor, select_word_at_cursor,
word_range_around,
};
use self::table_drag::{
commit_column_border_drag, commit_column_drag, commit_row_drag, current_widths_for_table,
delete_table_column_at, delete_table_row_at, resize_widths,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DragTarget {
TextSelection {
anchor: usize,
cell: Option<(usize, usize)>,
},
TableRow {
table_byte_start: usize,
row_idx: usize,
hover_row_idx: usize,
},
TableColumnBorder {
table_byte_start: usize,
col_idx: usize,
start_widths: Vec<usize>,
start_user_widths: Vec<Option<usize>>,
anchor_x: u16,
},
TableColumnHeader {
table_byte_start: usize,
col_idx: usize,
hover_col_idx: usize,
},
Scrollbar { grab_offset: u16 },
}
pub const MOUSE_SCROLL_OVERSHOOT: usize = 0;
pub fn hit_test_clickable(
state: &EditorState,
col: u16,
row: u16,
viewport_width: usize,
snapshots: &[TableLayoutSnapshot],
) -> bool {
hit_test_clickable_non_link(state, col, row, viewport_width, snapshots)
|| link_at_rendered_pos(state, col as usize, row as usize, viewport_width).is_some()
}
pub fn hit_test_clickable_non_link(
state: &EditorState,
col: u16,
row: u16,
viewport_width: usize,
snapshots: &[TableLayoutSnapshot],
) -> bool {
for snap in snapshots {
match snap.hit_test(col, row) {
Some(TableHit::RowHandle { .. })
| Some(TableHit::ColumnHandle { .. })
| Some(TableHit::DeleteRowHandle { .. })
| Some(TableHit::DeleteColumnHandle { .. }) => return true,
Some(TableHit::ColumnBorder { col_idx })
if col_idx > 0 && col_idx <= snap.col_count =>
{
return true;
}
_ => {}
}
}
let c = col as usize;
let r = row as usize;
let Some((line, visual_col)) = rendered_line_at_row(state, r) else {
return false;
};
let total_width: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
if c >= total_width {
return false;
}
let _ = visual_col;
if footnotes::back_link_glyph_at_click(state, col, row).is_some() {
return true;
}
if let Some(offset) = click_to_char_offset(state, c, r, viewport_width) {
let source = state.buffer.contents();
let click_byte = state.buffer.rope().char_to_byte(offset);
if footnotes::footnote_at_offset(&source, click_byte).is_some() {
return true;
}
if let Some(info) = list_edit::find_list_at(&source, click_byte) {
if let Some(item_idx) = list_edit::cursor_item_idx(&info, click_byte) {
let item = &info.items[item_idx];
if let Some(task_box) = item.task_box {
if click_byte >= item.start && click_byte < task_box + 3 {
return true;
}
}
}
}
}
false
}
fn apply_preview_action(
state: &mut EditorState,
action: MouseAction,
drag_target: &mut Option<DragTarget>,
viewport_width: usize,
) {
match action {
MouseAction::Click { col, row, .. } => {
if follow_link_at_click(state, col, row, viewport_width) {
*drag_target = None;
state.drag_in_progress = false;
return;
}
match rendered_click_to_line_col(state, col as usize, row as usize, viewport_width) {
Some((line_idx, char_col)) => {
let band = preview_table_cell_band(state, line_idx, char_col);
let char_col = match band {
Some(b) => {
let (lo, hi) = b.char_cols(&state.parsed.lines[line_idx]);
char_col.clamp(lo, hi)
}
None => char_col,
};
state.visual_selection = Some(VisualSelection {
anchor: (line_idx, char_col),
active: (line_idx, char_col),
band,
});
*drag_target = Some(DragTarget::TextSelection {
anchor: 0,
cell: None,
});
state.drag_in_progress = true;
}
None => {
state.visual_selection = None;
}
}
}
MouseAction::DoubleClick { col, row, .. } => {
if let Some((line_idx, char_col)) =
rendered_click_to_line_col(state, col as usize, row as usize, viewport_width)
{
if let Some((s, e)) = preview_word_range(state, line_idx, char_col) {
state.visual_selection =
Some(VisualSelection::span((line_idx, s), (line_idx, e)));
}
}
*drag_target = None;
state.drag_in_progress = false;
}
MouseAction::TripleClick { col, row, .. } => {
if let Some((line_idx, char_col)) =
rendered_click_to_line_col(state, col as usize, row as usize, viewport_width)
{
if let Some(b) = preview_table_cell_band(state, line_idx, char_col) {
let lines = &state.parsed.lines;
let start_col = lines.get(b.lines.0).map_or(0, |l| b.char_cols(l).0);
let end_col = lines.get(b.lines.1).map_or(start_col, |l| {
let (lo, hi) = b.char_cols(l);
let cell: String = l
.spans
.iter()
.flat_map(|s| s.content.chars())
.skip(lo)
.take(hi - lo)
.collect();
lo + cell.trim_end().chars().count()
});
state.visual_selection = Some(VisualSelection {
anchor: (b.lines.0, start_col),
active: (b.lines.1, end_col),
band: Some(b),
});
} else {
let end_col = state
.parsed
.lines
.get(line_idx)
.map(|l| l.spans.iter().map(|s| s.content.chars().count()).sum())
.unwrap_or(0);
state.visual_selection =
Some(VisualSelection::span((line_idx, 0), (line_idx, end_col)));
}
}
*drag_target = None;
state.drag_in_progress = false;
}
MouseAction::Drag { col, row } => {
if drag_target.is_none() {
return;
}
if let Some(mut active) =
rendered_click_to_line_col(state, col as usize, row as usize, viewport_width)
{
if let Some(sel) = state.visual_selection.as_mut() {
if let Some(b) = sel.band {
active.0 = active.0.clamp(b.lines.0, b.lines.1);
if let Some(line) = state.parsed.lines.get(active.0) {
let (lo, hi) = b.char_cols(line);
active.1 = active.1.clamp(lo, hi);
}
}
sel.active = active;
} else {
state.visual_selection = Some(VisualSelection::span(active, active));
}
}
}
MouseAction::Release => {
if let Some(sel) = state.visual_selection {
if sel.is_empty() {
state.visual_selection = None;
}
}
state.drag_in_progress = false;
*drag_target = None;
}
MouseAction::Scroll(delta) => {
scroll_by_mouse(state, delta, viewport_width);
}
}
}
fn preview_word_range(
state: &EditorState,
line_idx: usize,
char_col: usize,
) -> Option<(usize, usize)> {
let line = state.parsed.lines.get(line_idx)?;
let chars: Vec<char> = line.spans.iter().flat_map(|s| s.content.chars()).collect();
if chars.is_empty() {
return None;
}
let clamped = char_col.min(chars.len());
if let Some(range) = word_range_around(chars.len(), clamped, |i| chars[i]) {
return Some(range);
}
if clamped < chars.len() {
Some((clamped, clamped + 1))
} else {
None
}
}
fn dispatch_table_click(
state: &mut EditorState,
snapshots: &[TableLayoutSnapshot],
col: u16,
row: u16,
drag_target: &mut Option<DragTarget>,
viewport_height: usize,
viewport_width: usize,
) -> bool {
let Some((snap, hit)) = snapshots
.iter()
.find_map(|s| s.hit_test(col, row).map(|h| (s, h)))
else {
return false;
};
let acts_on_handle = match hit {
TableHit::Cell { .. } => false,
TableHit::ColumnBorder { col_idx } => col_idx > 0 && col_idx <= snap.col_count,
_ => true,
};
if acts_on_handle && focus_table_first(state, snap, viewport_height, viewport_width) {
*drag_target = None;
state.drag_in_progress = false;
return true;
}
match hit {
TableHit::RowHandle { row_idx } => {
*drag_target = Some(DragTarget::TableRow {
table_byte_start: snap.table_byte_start,
row_idx,
hover_row_idx: row_idx,
});
state.drag_in_progress = true;
true
}
TableHit::ColumnHandle { col_idx } => {
*drag_target = Some(DragTarget::TableColumnHeader {
table_byte_start: snap.table_byte_start,
col_idx,
hover_col_idx: col_idx,
});
state.drag_in_progress = true;
true
}
TableHit::ColumnBorder { col_idx } => {
if col_idx > 0 && col_idx <= snap.col_count {
let source = state.buffer.contents();
if let Some(info) = table_edit::find_table_at(&source, snap.table_byte_start) {
let (start_widths, start_user_widths) = current_widths_for_table(state, &info);
*drag_target = Some(DragTarget::TableColumnBorder {
table_byte_start: info.start,
col_idx,
start_widths,
start_user_widths,
anchor_x: col,
});
state.drag_in_progress = true;
return true;
}
}
false
}
TableHit::DeleteRowHandle { row_idx } => {
if !table_delete_allowed(state) {
return true;
}
delete_table_row_at(
state,
snap.table_byte_start,
row_idx,
viewport_height,
viewport_width,
);
*drag_target = None;
state.drag_in_progress = false;
true
}
TableHit::DeleteColumnHandle { col_idx } => {
if !table_delete_allowed(state) {
return true;
}
delete_table_column_at(
state,
snap.table_byte_start,
col_idx,
viewport_height,
viewport_width,
);
*drag_target = None;
state.drag_in_progress = false;
true
}
TableHit::Cell { .. } => false,
}
}
const TABLE_DELETE_COOLDOWN: Duration = Duration::from_millis(250);
fn table_delete_allowed(state: &EditorState) -> bool {
state
.last_table_delete_at
.is_none_or(|t| t.elapsed() >= TABLE_DELETE_COOLDOWN)
}
fn focus_table_first(
state: &mut EditorState,
snap: &TableLayoutSnapshot,
viewport_height: usize,
viewport_width: usize,
) -> bool {
let cursor_byte = state.buffer.rope().char_to_byte(state.cursor.offset);
if cursor_byte >= snap.table_byte_start && cursor_byte < snap.table_byte_end {
return false;
}
let offset = state
.buffer
.rope()
.byte_to_char(snap.table_byte_start.min(state.buffer.rope().len_bytes()));
state.selection = None;
state.cursor.offset = offset.min(state.buffer.len_chars());
state.cursor.preferred_col = state.current_visual_col(viewport_width);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
true
}
pub fn apply(
state: &mut EditorState,
action: MouseAction,
drag_target: &mut Option<DragTarget>,
snapshots: &[TableLayoutSnapshot],
viewport_height: usize,
viewport_width: usize,
) {
if state.mode == Mode::Preview {
apply_preview_action(state, action, drag_target, viewport_width);
return;
}
match action {
MouseAction::Click {
col,
row,
modifiers,
} => {
if modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
&& follow_link_at_click(state, col, row, viewport_width)
{
return;
}
if state.mode == Mode::Rendered
&& follow_footnote_at_click(state, col, row, viewport_width)
{
*drag_target = None;
state.drag_in_progress = false;
return;
}
if dispatch_table_click(
state,
snapshots,
col,
row,
drag_target,
viewport_height,
viewport_width,
) {
return;
}
if toggle_checkbox_at(state, col as usize, row as usize, viewport_width) {
*drag_target = None;
state.drag_in_progress = false;
return;
}
if let Some(block_idx) = image_block_at_click(state, row as usize, viewport_width) {
let already_revealed =
state.cursor_block_idx == Some(block_idx) && state.cursor_block_revealed();
if !already_revealed {
if let Some(target) = image_block_cursor_target(state, block_idx) {
state.selection = None;
state.cursor.offset = target;
state.cursor.preferred_col = state.current_visual_col(viewport_width);
state.update_cursor_block();
state.cursor_block_entered_at = None;
state.ensure_cursor_visible(viewport_height, viewport_width);
*drag_target = None;
state.drag_in_progress = false;
return;
}
}
}
if let Some(offset) =
click_to_char_offset(state, col as usize, row as usize, viewport_width)
{
state.selection = None;
let new_offset = offset.min(state.buffer.len_chars());
let new_line = state.buffer.char_to_line(new_offset);
let same_logical_line = state.cursor_line_idx == Some(new_line);
let cursor_block_is_table = state
.cursor_block_idx
.and_then(|idx| state.parsed.source_map.original_range_for_block(idx))
.map(|range| {
let source = state.buffer.contents();
let end = range.end.min(source.len());
table_edit::is_table_block(&source[range.start..end])
})
.unwrap_or(false);
let new_block_idx = state
.parsed
.source_map
.block_for_byte(state.buffer.rope().char_to_byte(new_offset));
let same_diagram_block = new_block_idx == state.cursor_block_idx
&& new_block_idx.is_some_and(|idx| state.parsed.is_diagram_reveal_block(idx));
let suppress_drag_flag =
(same_logical_line && !cursor_block_is_table) || same_diagram_block;
state.cursor.offset = new_offset;
state.cursor.preferred_col = state.current_visual_col(viewport_width);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
*drag_target = Some(DragTarget::TextSelection {
anchor: state.cursor.offset,
cell: if state.mode == Mode::Rendered {
table_cell_char_range_at(state, state.cursor.offset)
} else {
None
},
});
if !suppress_drag_flag {
state.drag_in_progress = true;
}
let source = state.buffer.contents();
let click_byte = state.buffer.rope().char_to_byte(state.cursor.offset);
if let Some(url) = link_at_offset(&source, click_byte) {
tracing::info!(target: "mouse", url = %url, "link clicked");
}
}
}
MouseAction::DoubleClick {
col,
row,
modifiers: _,
} => {
if dispatch_table_click(
state,
snapshots,
col,
row,
drag_target,
viewport_height,
viewport_width,
) {
return;
}
if let Some(offset) =
click_to_char_offset(state, col as usize, row as usize, viewport_width)
{
state.cursor.offset = offset.min(state.buffer.len_chars());
select_word_at_cursor(state);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
*drag_target = None;
}
}
MouseAction::TripleClick {
col,
row,
modifiers: _,
} => {
if dispatch_table_click(
state,
snapshots,
col,
row,
drag_target,
viewport_height,
viewport_width,
) {
return;
}
if let Some(offset) =
click_to_char_offset(state, col as usize, row as usize, viewport_width)
{
state.cursor.offset = offset.min(state.buffer.len_chars());
select_line_at_cursor(state);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
*drag_target = None;
}
}
MouseAction::Drag { col, row } => match drag_target {
Some(DragTarget::TextSelection { anchor, cell }) => {
let anchor = *anchor;
let cell = *cell;
if let Some(offset) =
click_to_char_offset(state, col as usize, row as usize, viewport_width)
{
let mut active = offset.min(state.buffer.len_chars());
if let Some((lo, hi)) = cell {
active = active.clamp(lo, hi);
}
state.cursor.offset = active;
state.cursor.preferred_col = state.current_visual_col(viewport_width);
state.selection = Some(Selection { anchor, active });
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
}
}
Some(DragTarget::TableRow {
table_byte_start,
hover_row_idx,
..
}) => {
if let Some(snap) = snapshots
.iter()
.find(|s| s.table_byte_start == *table_byte_start)
{
if let Some(row_idx) = snap.data_row_at_y(row) {
*hover_row_idx = row_idx;
}
}
}
Some(DragTarget::TableColumnBorder {
table_byte_start,
col_idx,
start_widths,
start_user_widths,
anchor_x,
}) => {
let delta = col as i32 - *anchor_x as i32;
if let Some(new_user_widths) = resize_widths(
start_widths,
start_user_widths,
*col_idx,
delta,
viewport_width,
) {
state.live_table_widths = Some((*table_byte_start, new_user_widths));
state.refresh_parsed();
}
}
Some(DragTarget::TableColumnHeader {
table_byte_start,
hover_col_idx,
..
}) => {
if let Some(snap) = snapshots
.iter()
.find(|s| s.table_byte_start == *table_byte_start)
{
if let Some(col_idx) = snap.column_at_x(col) {
*hover_col_idx = col_idx;
}
}
}
Some(DragTarget::Scrollbar { .. }) => {}
None => {}
},
MouseAction::Release => {
match drag_target.take() {
Some(DragTarget::TextSelection { .. }) => {
if let Some(sel) = state.selection {
if sel.is_empty() {
state.selection = None;
} else {
state.selection =
Some(expand_selection_to_inline_markers(&state.buffer, sel));
}
}
}
Some(DragTarget::TableRow {
table_byte_start,
row_idx,
hover_row_idx,
}) => {
commit_row_drag(state, table_byte_start, row_idx, hover_row_idx);
}
Some(DragTarget::TableColumnBorder {
table_byte_start, ..
}) => {
commit_column_border_drag(state, table_byte_start);
}
Some(DragTarget::TableColumnHeader {
table_byte_start,
col_idx,
hover_col_idx,
}) => {
commit_column_drag(state, table_byte_start, col_idx, hover_col_idx);
}
Some(DragTarget::Scrollbar { .. }) => {}
None => {}
}
state.drag_in_progress = false;
}
MouseAction::Scroll(delta) => {
scroll_by_mouse(state, delta, viewport_width);
}
}
}
fn image_block_at_click(state: &EditorState, row: usize, viewport_width: usize) -> Option<usize> {
if state.parsed.image_blocks.is_empty() {
return None;
}
let (line_idx, _) =
state.rendered_line_at_visual_row(state.scroll.saturating_add(row), viewport_width);
state
.parsed
.image_blocks
.iter()
.find(|info| {
state
.parsed
.source_map
.rendered_lines_for_block(info.block_idx)
.contains(&line_idx)
})
.map(|info| info.block_idx)
}
fn image_block_cursor_target(state: &EditorState, block_idx: usize) -> Option<usize> {
let range = state
.parsed
.source_map
.original_range_for_block(block_idx)?;
let source = state.buffer.contents();
let end = range.end.min(source.len());
let block_text = source.get(range.start..end)?;
let trimmed = block_text.trim_end_matches('\n');
let target_in_block = if state.parsed.is_mermaid_block(block_idx) {
trimmed.rfind("\n```").unwrap_or(trimmed.len())
} else if state.parsed.is_latex_block(block_idx) {
trimmed
.rfind("\n$$")
.or_else(|| trimmed.rfind("$$"))
.unwrap_or(trimmed.len())
} else {
trimmed.len()
};
let absolute_byte = range.start + target_in_block;
Some(
state
.buffer
.rope()
.byte_to_char(absolute_byte.min(source.len())),
)
}
pub fn set_scroll_absolute(
state: &mut EditorState,
position: usize,
viewport_width: usize,
viewport_height: usize,
) {
let total = state.total_visual_rows_for_mode(viewport_width);
let max_scroll = total.saturating_sub(viewport_height);
state.scroll = position.min(max_scroll);
}
pub fn scroll_by_mouse(state: &mut EditorState, delta: i32, _viewport_width: usize) {
if delta == 0 {
return;
}
let total = state.total_visual_rows_for_mode(state.viewport_width);
if total == 0 {
state.scroll = 0;
return;
}
let max_scroll = total.saturating_sub(1) + MOUSE_SCROLL_OVERSHOOT;
if delta > 0 {
state.scroll = (state.scroll + delta as usize).min(max_scroll);
} else {
state.scroll = state.scroll.saturating_sub((-delta) as usize);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Theme;
use crate::document::Buffer;
use crossterm::event::KeyModifiers;
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
fn click_plain(col: u16, row: u16) -> MouseAction {
MouseAction::Click {
col,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn scroll_down_mouse_respects_max() {
let mut state = EditorState::new(Buffer::from_str("a\nb\nc\nd\n"), theme());
state.mode = Mode::Rendered;
scroll_by_mouse(&mut state, 100, 80);
assert_eq!(state.scroll, state.parsed.line_count().saturating_sub(1));
}
#[test]
fn scroll_up_clamps_at_zero() {
let mut state = EditorState::new(Buffer::from_str("hello\nworld\n"), theme());
state.mode = Mode::Rendered;
state.scroll = 1;
scroll_by_mouse(&mut state, -5, 80);
assert_eq!(state.scroll, 0);
}
#[test]
fn click_places_cursor_on_paragraph() {
let text = "Hello world\n";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = Mode::Rendered;
let mut target: Option<DragTarget> = None;
apply(&mut state, click_plain(6, 0), &mut target, &[], 10, 80);
assert_eq!(state.cursor.offset, 6);
assert_eq!(state.selection, None);
assert_eq!(
target,
Some(DragTarget::TextSelection {
anchor: 6,
cell: None
})
);
}
#[test]
fn double_click_selects_word() {
let text = "hello world";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = Mode::Rendered;
state.cursor.offset = 7; select_word_at_cursor(&mut state);
assert_eq!(
state.selection,
Some(Selection {
anchor: 6,
active: 11
})
);
}
#[test]
fn triple_click_selects_line() {
let text = "first line\nsecond\n";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = Mode::Rendered;
state.cursor.offset = 3;
select_line_at_cursor(&mut state);
let sel = state.selection.expect("selection set");
assert_eq!(sel.anchor, 0);
assert_eq!(sel.active, 11); }
#[test]
fn click_target_lands_inside_a_latex_block_at_eof() {
let text = "intro\n\n$$\nE = mc^2\n$$";
let state = EditorState::new(Buffer::from_str(text), theme());
let block_idx = state
.parsed
.image_blocks
.iter()
.find(|i| state.parsed.is_latex_block(i.block_idx))
.expect("$$...$$ promotes to a latex image block")
.block_idx;
let target = image_block_cursor_target(&state, block_idx).expect("resolvable target");
let byte = state.buffer.rope().char_to_byte(target);
assert!(
byte < text.len(),
"target must land before the block's end, got byte {byte} of {}",
text.len()
);
assert_eq!(
state.parsed.source_map.block_for_byte(byte),
Some(block_idx),
"target must resolve back to the latex block so the reveal opens"
);
}
#[test]
fn drag_extends_selection() {
let text = "hello world";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = Mode::Rendered;
let mut target: Option<DragTarget> = None;
apply(&mut state, click_plain(0, 0), &mut target, &[], 10, 80);
apply(
&mut state,
MouseAction::Drag { col: 5, row: 0 },
&mut target,
&[],
10,
80,
);
let sel = state.selection.expect("drag selects");
assert_eq!(sel.anchor, 0);
assert_eq!(sel.active, 5);
}
#[test]
fn click_in_preview_stays_in_preview_and_seeds_visual_selection() {
let mut state = EditorState::new(Buffer::from_str("hello"), theme());
assert_eq!(state.mode, Mode::Preview);
let mut target: Option<DragTarget> = None;
apply(&mut state, click_plain(1, 0), &mut target, &[], 10, 80);
assert_eq!(state.mode, Mode::Preview);
let vs = state.visual_selection.expect("visual selection seeded");
assert_eq!(vs.anchor, (0, 1));
assert_eq!(vs.active, (0, 1));
}
#[test]
fn click_on_checkbox_toggles_it() {
let text = "- [ ] todo\n";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = Mode::Rendered;
let mut target: Option<DragTarget> = None;
apply(&mut state, click_plain(0, 0), &mut target, &[], 10, 80);
assert!(state.buffer.contents().contains("[x]"));
}
#[test]
fn click_past_end_of_line_clamps_to_line_end() {
let text = "hi\n";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = Mode::Rendered;
let mut target: Option<DragTarget> = None;
apply(&mut state, click_plain(50, 0), &mut target, &[], 10, 80);
assert!(state.cursor.offset <= 2);
}
#[test]
fn link_at_offset_detects_markdown_link() {
let src = "See [the docs](https://example.com) for more.\n";
assert_eq!(
link_at_offset(src, 8),
Some("https://example.com".to_owned())
);
assert_eq!(
link_at_offset(src, 20),
Some("https://example.com".to_owned())
);
}
#[test]
fn link_at_offset_returns_none_outside_link() {
let src = "See [the docs](https://example.com) for more.\n";
assert_eq!(link_at_offset(src, 40), None);
assert_eq!(link_at_offset(src, 1), None);
}
#[test]
fn link_at_offset_ignores_escaped_bracket() {
let src = r"See \[the docs](https://example.com) for more.";
assert_eq!(link_at_offset(src, 9), None);
let live = r"See \\[the docs](https://example.com) end";
assert_eq!(
link_at_offset(live, 9),
Some("https://example.com".to_owned())
);
}
#[test]
fn link_at_offset_handles_nested_brackets() {
let src = "[one [nested] two](https://ex.com)\n";
assert_eq!(link_at_offset(src, 7), Some("https://ex.com".to_owned()));
}
#[test]
fn raw_mode_click_places_cursor_on_line() {
let text = "first\nsecond\nthird\n";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = Mode::Raw;
let mut target: Option<DragTarget> = None;
apply(&mut state, click_plain(2, 1), &mut target, &[], 10, 80);
assert_eq!(state.cursor.offset, 8);
}
#[test]
fn click_in_highlight_places_cursor_correctly() {
let text = "x\nalpha ==beta== gamma\n";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = Mode::Rendered;
let mut target: Option<DragTarget> = None;
apply(&mut state, click_plain(8, 1), &mut target, &[], 10, 80);
assert_eq!(state.cursor.offset, 2 + 10);
}
#[test]
fn click_in_bold_inside_list_item_places_cursor_correctly() {
let text = "x\n- **bold** text\n";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = Mode::Rendered;
let mut target: Option<DragTarget> = None;
apply(&mut state, click_plain(3, 1), &mut target, &[], 10, 80);
assert_eq!(state.cursor.offset, 2 + 5);
}
#[test]
fn click_in_bold_inside_ordered_list_item_places_cursor_correctly() {
let text = "x\n1. **bold** text\n";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = Mode::Rendered;
let mut target: Option<DragTarget> = None;
apply(&mut state, click_plain(4, 1), &mut target, &[], 10, 80);
assert_eq!(state.cursor.offset, 2 + 6);
}
#[test]
fn click_in_bold_inside_blockquote_places_cursor_correctly() {
let text = "x\n> **bold** text\n";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = Mode::Rendered;
let mut target: Option<DragTarget> = None;
apply(&mut state, click_plain(3, 1), &mut target, &[], 10, 80);
assert_eq!(state.cursor.offset, 2 + 5);
}
}