use crate::config::Action;
use crate::document::{next_grapheme_offset, prev_grapheme_offset, Buffer, EditDelta, Selection};
use crate::editor::footnote_edit;
use crate::editor::list_edit::{self, ListInfo};
use crate::editor::table_edit;
use crate::editor::table_edit_ops::{
cursor_in_table, table_delete_column, table_delete_row, table_insert_column, table_insert_row,
table_move_column, table_move_horizontal, table_move_row, table_next_cell, table_next_row,
table_prev_cell, table_prev_row, try_move_cell_vertical,
};
use crate::editor::{EditorState, Mode};
fn is_hot_typing_action(action: &Action) -> bool {
matches!(
action,
Action::InsertChar(_)
| Action::InsertTab
| Action::Newline
| Action::DeleteCharBack
| Action::DeleteCharForward
| Action::DeleteWordBack
| Action::DeleteWordForward
)
}
pub fn apply(
state: &mut EditorState,
action: Action,
viewport_height: usize,
viewport_width: usize,
) -> bool {
if !is_hot_typing_action(&action) {
state.flush_parsed_if_dirty();
}
let buffer_len_before = state.buffer.len_chars();
let history_depth_before = state.history.undo_depth();
let suppress_autonumber = matches!(action, Action::Undo | Action::Redo);
match action {
Action::Quit => return true,
Action::EnterEditMode => {
if state.mode == Mode::Preview && !state.readonly {
sync_cursor_to_scroll(state, viewport_height);
state.mode = Mode::Rendered;
state.visual_selection = None;
}
}
Action::ExitToPreview => {
state.mode = Mode::Preview;
state.selection = None;
state.visual_selection = None;
}
Action::ToggleRawMode => {
if state.readonly {
return false;
}
if state.mode == Mode::Preview {
sync_cursor_to_scroll(state, viewport_height);
}
state.visual_selection = None;
let preserve_screen_row = if state.mode == Mode::Preview {
None
} else {
Some(state.cursor_screen_row(viewport_width))
};
let was_raw = state.mode == Mode::Raw;
state.mode = match state.mode {
Mode::Preview => Mode::Rendered,
Mode::Rendered => Mode::Raw,
Mode::Raw => Mode::Rendered,
Mode::Diff => Mode::Diff,
};
if was_raw && state.mode == Mode::Rendered {
snap_cursor_out_of_hidden_block(state, viewport_width);
state.update_cursor_block();
}
if let Some(row) = preserve_screen_row {
state.set_scroll_for_cursor_screen_row(row, viewport_width);
}
}
Action::MoveLeft => {
enter_edit_if_preview(state, viewport_height);
state.selection = None;
if state.mode == Mode::Raw
|| (!table_move_horizontal(state, false)
&& !list_move_horizontal(state, false))
{
state.cursor.move_left(&state.buffer);
}
sync_preferred_visual(state, viewport_width);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
}
Action::MoveRight => {
enter_edit_if_preview(state, viewport_height);
state.selection = None;
if state.mode == Mode::Raw
|| (!table_move_horizontal(state, true)
&& !list_move_horizontal(state, true))
{
state.cursor.move_right(&state.buffer);
}
sync_preferred_visual(state, viewport_width);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
}
Action::MoveUp => {
if state.mode == Mode::Preview {
state.scroll_up(1);
return false;
}
state.selection = None;
if state.mode == Mode::Raw {
if state.visual_line_nav && viewport_width > 0 {
state.move_up_visual(viewport_width);
} else {
state.cursor.move_up(&state.buffer);
}
} else if !try_move_cell_vertical(
state,
false,
viewport_height,
viewport_width,
) {
move_line_skipping_alignment(state, false, viewport_width);
}
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
}
Action::MoveDown => {
if state.mode == Mode::Preview {
state.scroll_down(1, viewport_height);
return false;
}
state.selection = None;
if state.mode == Mode::Raw {
if state.visual_line_nav && viewport_width > 0 {
state.move_down_visual(viewport_width);
} else {
state.cursor.move_down(&state.buffer);
}
} else if !try_move_cell_vertical(
state,
true,
viewport_height,
viewport_width,
) {
move_line_skipping_alignment(state, true, viewport_width);
}
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
}
Action::MoveWordLeft => {
enter_edit_if_preview(state, viewport_height);
state.selection = None;
state.cursor.move_word_left(&state.buffer);
sync_preferred_visual(state, viewport_width);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
}
Action::MoveWordRight => {
enter_edit_if_preview(state, viewport_height);
state.selection = None;
state.cursor.move_word_right(&state.buffer);
sync_preferred_visual(state, viewport_width);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
}
Action::MoveLineStart => {
enter_edit_if_preview(state, viewport_height);
state.selection = None;
state.cursor.move_line_start(&state.buffer);
sync_preferred_visual(state, viewport_width);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
}
Action::MoveLineEnd => {
enter_edit_if_preview(state, viewport_height);
state.selection = None;
state.cursor.move_line_end(&state.buffer);
sync_preferred_visual(state, viewport_width);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
}
Action::MoveDocStart => {
state.selection = None;
state.cursor.move_doc_start();
state.update_cursor_block();
state.scroll_to_top();
}
Action::MoveDocEnd => {
state.selection = None;
state.cursor.move_doc_end(&state.buffer);
state.update_cursor_block();
state.scroll_to_bottom(viewport_height, viewport_width);
state.ensure_cursor_visible(viewport_height, viewport_width);
}
Action::SelectLeft => {
enter_edit_if_preview(state, viewport_height);
let anchor = state
.selection
.map(|s| s.anchor)
.unwrap_or(state.cursor.offset);
state.cursor.move_left(&state.buffer);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
state.selection = Some(Selection {
anchor,
active: state.cursor.offset,
});
}
Action::SelectRight => {
enter_edit_if_preview(state, viewport_height);
let anchor = state
.selection
.map(|s| s.anchor)
.unwrap_or(state.cursor.offset);
state.cursor.move_right(&state.buffer);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
state.selection = Some(Selection {
anchor,
active: state.cursor.offset,
});
}
Action::SelectUp => {
enter_edit_if_preview(state, viewport_height);
let anchor = state
.selection
.map(|s| s.anchor)
.unwrap_or(state.cursor.offset);
state.cursor.move_up(&state.buffer);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
state.selection = Some(Selection {
anchor,
active: state.cursor.offset,
});
}
Action::SelectDown => {
enter_edit_if_preview(state, viewport_height);
let anchor = state
.selection
.map(|s| s.anchor)
.unwrap_or(state.cursor.offset);
state.cursor.move_down(&state.buffer);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
state.selection = Some(Selection {
anchor,
active: state.cursor.offset,
});
}
Action::SelectAll => {
if state.mode == Mode::Preview {
let lines = &state.parsed.lines;
if !lines.is_empty() {
let last = lines.len() - 1;
let last_col: usize = lines[last]
.spans
.iter()
.map(|s| s.content.chars().count())
.sum();
state.visual_selection = Some(crate::document::VisualSelection::span(
(0, 0),
(last, last_col),
));
}
return false;
}
state.selection = Some(Selection {
anchor: 0,
active: state.buffer.len_chars(),
});
state.cursor.move_doc_end(&state.buffer);
}
Action::ScrollUp => state.scroll_up(1),
Action::ScrollDown => {
state.scroll_down(1, viewport_height);
state.clamp_cursor_to_viewport_top();
}
Action::ScrollPageUp => state.scroll_up(viewport_height),
Action::ScrollPageDown => {
state.scroll_down(viewport_height, viewport_height);
state.clamp_cursor_to_viewport_top();
}
Action::ScrollToTop => state.scroll_to_top(),
Action::ScrollToBottom => {
state.scroll_to_bottom(viewport_height, viewport_width);
}
Action::InsertChar(ch) => {
if state.mode == Mode::Preview {
sync_cursor_to_scroll(state, viewport_height);
state.mode = Mode::Rendered;
return false;
}
if ch == '|' && cursor_in_table(state) {
insert_text(state, "\\|");
} else {
insert_text(state, &ch.to_string());
}
}
Action::InsertTab => {
if state.mode == Mode::Preview {
sync_cursor_to_scroll(state, viewport_height);
state.mode = Mode::Rendered;
return false;
}
if cursor_in_table(state) {
table_next_cell(state, viewport_height, viewport_width);
} else if !list_indent(state) {
let indent: String = " ".repeat(crate::constants::INDENT_WIDTH);
insert_text(state, &indent);
}
}
Action::Newline => {
if state.mode == Mode::Preview {
sync_cursor_to_scroll(state, viewport_height);
state.mode = Mode::Rendered;
return false;
}
if cursor_in_table(state) {
table_next_row(state, viewport_height, viewport_width);
} else if !list_handle_newline(state) {
insert_text(state, "\n");
}
}
Action::DeleteCharBack => {
enter_edit_if_preview(state, viewport_height);
if let Some(sel) = state.selection.take() {
delete_selection(state, sel);
} else if state.mode == Mode::Rendered && list_backspace_consumes_marker(state) {
} else if state.cursor.offset > 0 {
let end = state.cursor.offset;
let offset = prev_grapheme_offset(&state.buffer, end);
let removed = state.buffer.slice_to_string(offset, end);
state.apply_delta(EditDelta {
offset,
removed,
inserted: String::new(),
});
}
}
Action::DeleteCharForward => {
enter_edit_if_preview(state, viewport_height);
if let Some(sel) = state.selection.take() {
delete_selection(state, sel);
} else if state.cursor.offset < state.buffer.len_chars() {
let offset = state.cursor.offset;
let end = next_grapheme_offset(&state.buffer, offset);
let removed = state.buffer.slice_to_string(offset, end);
state.apply_delta(EditDelta {
offset,
removed,
inserted: String::new(),
});
}
}
Action::DeleteWordBack => {
enter_edit_if_preview(state, viewport_height);
state.selection = None;
let end = state.cursor.offset;
let mut temp = state.cursor;
temp.move_word_left(&state.buffer);
let start = temp.offset;
if start < end {
let removed = state.buffer.slice_to_string(start, end);
state.cursor.offset = start;
state.cursor.preferred_col = state.cursor.cell_col(&state.buffer);
state.apply_delta(EditDelta {
offset: start,
removed,
inserted: String::new(),
});
}
}
Action::DeleteWordForward => {
enter_edit_if_preview(state, viewport_height);
state.selection = None;
let start = state.cursor.offset;
let mut temp = state.cursor;
temp.move_word_right(&state.buffer);
let end = temp.offset;
if start < end {
let removed = state.buffer.slice_to_string(start, end);
state.apply_delta(EditDelta {
offset: start,
removed,
inserted: String::new(),
});
}
}
Action::DeleteLine => {
enter_edit_if_preview(state, viewport_height);
state.selection = None;
let (line, _) = state.cursor.line_col(&state.buffer);
let start = state.buffer.line_to_char(line);
let end = if line + 1 < state.buffer.line_count() {
state.buffer.line_to_char(line + 1)
} else {
state.buffer.len_chars()
};
if start < end {
let removed = state.buffer.slice_to_string(start, end);
state.cursor.offset = start.min(state.buffer.len_chars().saturating_sub(1));
state.apply_delta(EditDelta {
offset: start,
removed,
inserted: String::new(),
});
}
}
Action::Undo => {
if let Some(offset) = state.history.undo(&mut state.buffer) {
state.cursor.offset = offset.min(state.buffer.len_chars());
state.refresh_parsed();
state.ensure_cursor_visible(viewport_height, viewport_width);
}
}
Action::Redo => {
if let Some(offset) = state.history.redo(&mut state.buffer) {
state.cursor.offset = offset.min(state.buffer.len_chars());
state.refresh_parsed();
state.ensure_cursor_visible(viewport_height, viewport_width);
}
}
Action::Copy => {
let text = if state.mode == Mode::Preview {
if let Some(vs) = state.visual_selection {
crate::editor::mouse_ops::visual_selection_to_rendered_text(
vs,
&state.parsed.lines,
)
} else {
state
.parsed
.lines
.get(state.scroll)
.map(|line| {
line.spans
.iter()
.flat_map(|s| s.content.chars())
.collect::<String>()
})
.unwrap_or_default()
}
} else if let Some(sel) = &state.selection {
sel.selected_text(&state.buffer)
} else {
let (line, _) = state.cursor.line_col(&state.buffer);
state.buffer.line(line).unwrap_or_default()
};
copy_to_clipboard(state, text);
}
Action::Cut => {
if let Some(sel) = state.selection.take() {
let text = sel.selected_text(&state.buffer);
copy_to_clipboard(state, text.clone());
delete_selection_text(state, &sel);
} else {
let (line, _) = state.cursor.line_col(&state.buffer);
let start = state.buffer.line_to_char(line);
let end = if line + 1 < state.buffer.line_count() {
state.buffer.line_to_char(line + 1)
} else {
state.buffer.len_chars()
};
if start < end {
let text = state.buffer.slice_to_string(start, end);
copy_to_clipboard(state, text.clone());
state.cursor.offset = start;
state.apply_delta(EditDelta {
offset: start,
removed: text,
inserted: String::new(),
});
}
}
}
Action::Paste => {
let text = state.kill_ring.clone();
paste_text(state, &text, viewport_height, viewport_width);
}
Action::BoldSelection => toggle_wrap(state, "**"),
Action::ItalicizeSelection => toggle_wrap(state, "*"),
Action::InlineCodeSelection => toggle_wrap(state, "`"),
Action::StrikethroughSelection => toggle_wrap(state, "~~"),
Action::HighlightSelection => toggle_wrap(state, "=="),
Action::ToggleCheckbox => {
enter_edit_if_preview(state, viewport_height);
list_toggle_checkbox(state);
}
Action::TableNextCell => {
enter_edit_if_preview(state, viewport_height);
if cursor_in_table(state) {
table_next_cell(state, viewport_height, viewport_width);
}
}
Action::TablePrevCell => {
enter_edit_if_preview(state, viewport_height);
if cursor_in_table(state) {
table_prev_cell(state, viewport_height, viewport_width);
} else {
list_outdent(state);
}
}
Action::TableNextRow => {
enter_edit_if_preview(state, viewport_height);
if cursor_in_table(state) {
table_next_row(state, viewport_height, viewport_width);
}
}
Action::TablePrevRow => {
enter_edit_if_preview(state, viewport_height);
if cursor_in_table(state) {
table_prev_row(state, viewport_height, viewport_width);
}
}
Action::TableMoveRowUp => {
table_move_row(state, false, viewport_height, viewport_width);
}
Action::TableMoveRowDown => {
table_move_row(state, true, viewport_height, viewport_width);
}
Action::TableMoveColumnLeft => {
table_move_column(
state,
false,
viewport_height,
viewport_width,
);
}
Action::TableMoveColumnRight => {
table_move_column(state, true, viewport_height, viewport_width);
}
Action::TableInsertRowAbove => {
table_insert_row(
state,
false,
viewport_height,
viewport_width,
);
}
Action::TableInsertRowBelow => {
table_insert_row(state, true, viewport_height, viewport_width);
}
Action::TableInsertColumnLeft => {
table_insert_column(
state,
false,
viewport_height,
viewport_width,
);
}
Action::TableInsertColumnRight => {
table_insert_column(state, true, viewport_height, viewport_width);
}
Action::TableDeleteRow => {
table_delete_row(state, viewport_height, viewport_width);
}
Action::TableDeleteColumn => {
table_delete_column(state, viewport_height, viewport_width);
}
Action::TableInsertBreak => {
enter_edit_if_preview(state, viewport_height);
if cursor_in_table(state) {
insert_text(state, "<br>");
} else {
insert_text(state, "\n");
}
}
_ => {}
}
let edited = state.buffer.len_chars() != buffer_len_before
|| state.history.undo_depth() != history_depth_before;
if !suppress_autonumber && state.mode == Mode::Rendered && edited {
list_renumber_at_cursor(state);
}
if state.mode == Mode::Rendered && !suppress_autonumber {
clamp_cursor_out_of_marker(state);
}
if edited && state.mode != Mode::Preview {
if state.mode != Mode::Raw {
state.flush_parsed_if_dirty();
}
state.ensure_cursor_visible(viewport_height, viewport_width);
}
false
}
fn enter_edit_if_preview(state: &mut EditorState, viewport_height: usize) {
if state.readonly {
return;
}
if state.mode == Mode::Preview {
sync_cursor_to_scroll(state, viewport_height);
state.mode = Mode::Rendered;
state.visual_selection = None;
}
}
fn sync_preferred_visual(state: &mut EditorState, viewport_width: usize) {
if state.visual_line_nav && viewport_width > 0 {
state.cursor.preferred_col = state.current_visual_col(viewport_width);
}
}
fn sync_cursor_to_scroll(state: &mut EditorState, viewport_height: usize) {
state.cursor.offset = synced_cursor_offset(state, viewport_height);
}
fn synced_cursor_offset(state: &EditorState, viewport_height: usize) -> usize {
let scroll = state.scroll;
let cursor_byte = state.buffer.rope().char_to_byte(state.cursor.offset);
let cursor_lines = state.parsed.source_map.rendered_lines_for_byte(cursor_byte);
if !cursor_lines.is_empty() {
let visible_end = scroll + viewport_height;
if cursor_lines.start >= scroll && cursor_lines.start < visible_end {
return state.cursor.offset;
}
}
match state
.parsed
.source_map
.original_byte_for_rendered_line(scroll)
{
Some(byte) => state
.buffer
.rope()
.byte_to_char(byte)
.min(state.buffer.len_chars()),
None => state.cursor.offset,
}
}
pub fn apply_insert_run(
state: &mut EditorState,
chars: &[char],
viewport_height: usize,
viewport_width: usize,
) {
if chars.is_empty() {
return;
}
let in_table = cursor_in_table(state);
let mut text = String::with_capacity(chars.len());
for &ch in chars {
if ch == '|' && in_table {
text.push_str("\\|");
} else {
text.push(ch);
}
}
insert_text(state, &text);
if state.mode == Mode::Rendered {
list_renumber_at_cursor(state);
clamp_cursor_out_of_marker(state);
}
if state.mode != Mode::Raw {
state.flush_parsed_if_dirty();
}
state.ensure_cursor_visible(viewport_height, viewport_width);
}
pub fn apply_delete_run(
state: &mut EditorState,
count: usize,
backward: bool,
viewport_height: usize,
viewport_width: usize,
) {
if count == 0 {
return;
}
let buffer_len = state.buffer.len_chars();
let (start, end) = if backward {
let mut off = state.cursor.offset;
for _ in 0..count {
if off == 0 {
break;
}
off = prev_grapheme_offset(&state.buffer, off);
}
(off, state.cursor.offset)
} else {
let mut off = state.cursor.offset;
for _ in 0..count {
if off >= buffer_len {
break;
}
off = next_grapheme_offset(&state.buffer, off);
}
(state.cursor.offset, off)
};
if start >= end {
return;
}
let removed = state.buffer.slice_to_string(start, end);
state.apply_delta(EditDelta {
offset: start,
removed,
inserted: String::new(),
});
if state.mode == Mode::Rendered {
list_renumber_at_cursor(state);
clamp_cursor_out_of_marker(state);
}
if state.mode != Mode::Raw {
state.flush_parsed_if_dirty();
}
state.ensure_cursor_visible(viewport_height, viewport_width);
}
fn insert_text(state: &mut EditorState, text: &str) {
let offset = state.cursor.offset;
if let Some(sel) = state.selection.take() {
let (start, end) = sel.range();
let removed = state
.buffer
.slice_to_string(start, end.min(state.buffer.len_chars()));
state.cursor.offset = start;
state.apply_delta(EditDelta {
offset: start,
removed,
inserted: text.to_owned(),
});
} else {
state.apply_delta(EditDelta {
offset,
removed: String::new(),
inserted: text.to_owned(),
});
}
}
fn delete_selection(state: &mut EditorState, sel: Selection) {
delete_selection_text(state, &sel);
}
fn delete_selection_text(state: &mut EditorState, sel: &Selection) {
let (start, end) = sel.range();
let end = end.min(state.buffer.len_chars());
if start < end {
let removed = state.buffer.slice_to_string(start, end);
state.cursor.offset = start;
state.apply_delta(EditDelta {
offset: start,
removed,
inserted: String::new(),
});
}
}
fn toggle_wrap(state: &mut EditorState, marker: &str) {
let Some(sel) = state.selection else {
return;
};
let (start, end) = sel.range();
let end = end.min(state.buffer.len_chars());
if start >= end {
return;
}
let text = state.buffer.slice_to_string(start, end);
if text.contains('\n') {
return;
}
let mlen = marker.len();
let (remove_start, removed, inserted, inner_start, inner_len) =
if is_marker_wrapped(&text, marker) {
let inner = text[mlen..text.len() - mlen].to_string();
let n = inner.chars().count();
(start, text, inner, start, n)
} else if outside_wrapped(&state.buffer, start, end, marker) {
let removed = state.buffer.slice_to_string(start - mlen, end + mlen);
let n = text.chars().count();
(start - mlen, removed, text, start - mlen, n)
} else {
let n = text.chars().count();
let inserted = format!("{marker}{text}{marker}");
(start, text, inserted, start + mlen, n)
};
state.apply_delta(EditDelta {
offset: remove_start,
removed,
inserted,
});
let inner_end = inner_start + inner_len;
state.selection = Some(Selection {
anchor: inner_start,
active: inner_end,
});
state.cursor.offset = inner_end;
}
fn is_marker_wrapped(text: &str, marker: &str) -> bool {
let mlen = marker.len();
if text.len() < 2 * mlen || !text.starts_with(marker) || !text.ends_with(marker) {
return false;
}
if marker == "*" && (text.starts_with("**") || text.ends_with("**")) {
return false;
}
!text[mlen..text.len() - mlen].contains(marker)
}
fn outside_wrapped(buf: &Buffer, start: usize, end: usize, marker: &str) -> bool {
let mlen = marker.len();
if start < mlen || end + mlen > buf.len_chars() {
return false;
}
if buf.slice_to_string(start - mlen, start) != marker
|| buf.slice_to_string(end, end + mlen) != marker
{
return false;
}
if marker == "*" {
let bold_before = start >= 2 && buf.slice_to_string(start - 2, start - 1) == "*";
let bold_after = end + 2 <= buf.len_chars() && buf.slice_to_string(end + 1, end + 2) == "*";
if bold_before || bold_after {
return false;
}
}
true
}
fn copy_to_clipboard(state: &mut EditorState, text: String) {
if text.is_empty() {
return;
}
state.pending_clipboard_write = Some(text.clone());
state.kill_ring = text;
}
pub fn paste_text(
state: &mut EditorState,
text: &str,
viewport_height: usize,
viewport_width: usize,
) {
if text.is_empty() {
return;
}
let text = crate::document::buffer::normalize_newlines(text.to_owned());
let text = text.as_str();
let buffer_len_before = state.buffer.len_chars();
let history_depth_before = state.history.undo_depth();
enter_edit_if_preview(state, viewport_height);
if let Some(sel) = state.selection.take() {
let (start, end) = sel.range();
let removed = state
.buffer
.slice_to_string(start, end.min(state.buffer.len_chars()));
state.cursor.offset = start;
state.apply_delta(EditDelta {
offset: start,
removed,
inserted: text.to_owned(),
});
} else {
insert_text(state, text);
}
let edited = state.buffer.len_chars() != buffer_len_before
|| state.history.undo_depth() != history_depth_before;
if state.mode == Mode::Rendered && edited {
list_renumber_at_cursor(state);
}
if state.mode == Mode::Rendered {
clamp_cursor_out_of_marker(state);
}
state.ensure_cursor_visible(viewport_height, viewport_width);
}
pub fn insert_table_at_cursor(
state: &mut EditorState,
rows: usize,
cols: usize,
viewport_height: usize,
viewport_width: usize,
) {
enter_edit_if_preview(state, viewport_height);
let source = state.buffer.contents();
let cursor_byte = cursor_byte(state);
let (byte_delta, cursor_target) = table_edit::insert_table(&source, cursor_byte, rows, cols);
apply_byte_delta(state, byte_delta, cursor_target);
state.ensure_cursor_visible(viewport_height, viewport_width);
}
pub const URL_PLACEHOLDER: &str = "file path or URL";
#[allow(dead_code)]
pub fn cursor_block_allows_inline_markdown(state: &mut EditorState) -> bool {
let offset = state.cursor.offset;
block_allows_inline_markdown_at(state, offset)
}
fn block_allows_inline_markdown_at(state: &mut EditorState, char_offset: usize) -> bool {
use crate::markdown::Block;
state.flush_parsed_if_dirty();
let byte = state.buffer.rope().char_to_byte(char_offset);
let Some(block) = state.parsed.real_block_for_byte(byte) else {
return true;
};
!matches!(
block,
Block::CodeBlock { .. }
| Block::Html(_)
| Block::HtmlComment(_)
| Block::HorizontalRule
| Block::ImageBlock { .. }
| Block::MetadataBlock { .. }
)
}
pub fn insert_image_at_cursor(
state: &mut EditorState,
viewport_height: usize,
viewport_width: usize,
) -> bool {
insert_inline_snippet(
state,
"!",
"alt text",
SnippetUrl::Placeholder,
viewport_height,
viewport_width,
)
}
pub fn insert_link_at_cursor(
state: &mut EditorState,
viewport_height: usize,
viewport_width: usize,
) -> bool {
insert_inline_snippet(
state,
"",
"link text",
SnippetUrl::Placeholder,
viewport_height,
viewport_width,
)
}
pub fn insert_image_reference_at_cursor(
state: &mut EditorState,
url: &str,
viewport_height: usize,
viewport_width: usize,
) -> bool {
use crate::markdown::Block;
if !can_insert_image_reference(state, viewport_height) {
return false;
}
let byte = state
.buffer
.rope()
.char_to_byte(snippet_anchor(state, viewport_height));
let url = match state.parsed.real_block_for_byte(byte) {
Some(
Block::Table { .. }
| Block::List { .. }
| Block::BlockQuote { .. }
| Block::FootnoteDefinition { .. },
) => SnippetUrl::Inline(crate::markdown::escape_destination_in_table(url)),
_ => SnippetUrl::OwnParagraph(crate::markdown::escape_destination(url)),
};
insert_inline_snippet(state, "!", "", url, viewport_height, viewport_width)
}
pub fn can_insert_image_reference(state: &mut EditorState, viewport_height: usize) -> bool {
let at = snippet_anchor(state, viewport_height);
if !block_allows_inline_markdown_at(state, at) {
return false;
}
let byte = state.buffer.rope().char_to_byte(at);
!matches!(
state.parsed.real_block_for_byte(byte),
Some(crate::markdown::Block::Heading { .. })
)
}
fn snippet_anchor(state: &EditorState, viewport_height: usize) -> usize {
snippet_wrap(state).map_or_else(
|| {
if state.mode == Mode::Preview {
synced_cursor_offset(state, viewport_height)
} else {
state.cursor.offset
}
},
|(start, _)| start,
)
}
fn snippet_wrap(state: &EditorState) -> Option<(usize, String)> {
let sel = state.selection.as_ref()?;
let (start, end) = sel.range();
let end = end.min(state.buffer.len_chars());
if start >= end {
return None;
}
let text = state.buffer.slice_to_string(start, end);
(!text.contains('\n')).then_some((start, text))
}
fn frame_own_paragraph(
buffer: &crate::document::Buffer,
start: usize,
end: usize,
reference: &str,
) -> String {
let rope = buffer.rope();
let len = rope.len_chars();
let start = start.min(len);
let end = end.clamp(start, len);
let at_line_start = start == 0 || rope.char(start - 1) == '\n';
let above_blank = start < 2 || rope.char(start - 2) == '\n';
let mut out = String::new();
if !(at_line_start && above_blank) {
out.push_str(if at_line_start { "\n" } else { "\n\n" });
}
out.push_str(reference);
if end >= len {
out.push('\n');
} else if rope.char(end) != '\n' {
out.push_str("\n\n");
} else if end + 1 < len && rope.char(end + 1) != '\n' {
out.push('\n');
}
out
}
enum SnippetUrl {
Placeholder,
Inline(String),
OwnParagraph(String),
}
fn insert_inline_snippet(
state: &mut EditorState,
prefix: &str,
text_placeholder: &str,
url: SnippetUrl,
viewport_height: usize,
viewport_width: usize,
) -> bool {
if !block_allows_inline_markdown_at(state, snippet_anchor(state, viewport_height)) {
return false;
}
if state.mode == Mode::Preview {
sync_cursor_to_scroll(state, viewport_height);
}
let wrap = snippet_wrap(state);
state.selection = None;
enter_edit_if_preview(state, viewport_height);
let (offset, removed, visible_text, select_placeholder_url) = match wrap {
Some((start, text)) => (start, text.clone(), text, true),
None => (
state.cursor.offset,
String::new(),
text_placeholder.to_owned(),
false,
),
};
let dest = match &url {
SnippetUrl::Placeholder => URL_PLACEHOLDER,
SnippetUrl::Inline(url) | SnippetUrl::OwnParagraph(url) => url.as_str(),
};
let reference = format!("{prefix}[{visible_text}]({dest})");
let inserted = if matches!(url, SnippetUrl::OwnParagraph(_)) {
let end = offset + removed.chars().count();
frame_own_paragraph(&state.buffer, offset, end, &reference)
} else {
reference
};
let inserted_len = inserted.chars().count();
let lead = inserted.chars().take_while(|&c| c == '\n').count();
state.cursor.offset = offset;
state.apply_delta(EditDelta {
offset,
removed,
inserted,
});
if matches!(url, SnippetUrl::Placeholder) {
let (sel_start, sel_len) = if select_placeholder_url {
(
offset + prefix.len() + 1 + visible_text.chars().count() + 2,
URL_PLACEHOLDER.len(),
)
} else {
(offset + prefix.len() + 1, text_placeholder.len())
};
let sel_end = sel_start + sel_len;
state.selection = Some(Selection {
anchor: sel_start,
active: sel_end,
});
state.cursor.offset = sel_end;
} else if visible_text.is_empty() {
state.cursor.offset = offset + lead + prefix.len() + 1;
} else {
state.cursor.offset = offset + inserted_len;
}
state.cursor.preferred_col = state.cursor.cell_col(&state.buffer);
state.update_cursor_block();
state.ensure_cursor_visible(viewport_height, viewport_width);
true
}
pub fn insert_footnote_at_cursor(
state: &mut EditorState,
viewport_height: usize,
viewport_width: usize,
) {
enter_edit_if_preview(state, viewport_height);
let source = state.buffer.contents();
let cursor_byte = cursor_byte(state);
let (delta, cursor_target) = footnote_edit::insert_footnote(&source, cursor_byte);
apply_byte_delta(state, delta, cursor_target);
state.ensure_cursor_visible(viewport_height, viewport_width);
}
pub fn renumber_footnotes(
state: &mut EditorState,
viewport_height: usize,
viewport_width: usize,
) -> bool {
let source = state.buffer.contents();
let Some(delta) = footnote_edit::renumber_footnotes(&source) else {
return false;
};
let cursor_target = cursor_byte(state);
apply_byte_delta(state, delta, cursor_target);
state.ensure_cursor_visible(viewport_height, viewport_width);
true
}
pub fn delete_footnote_at_cursor(
state: &mut EditorState,
viewport_height: usize,
viewport_width: usize,
) -> bool {
let source = state.buffer.contents();
let cursor_byte = cursor_byte(state);
let Some(label) = footnote_edit::label_at(&source, cursor_byte) else {
return false;
};
let Some(delta) = footnote_edit::delete_footnote(&source, &label) else {
return false;
};
let cursor_target = delta.offset;
apply_byte_delta(state, delta, cursor_target);
state.ensure_cursor_visible(viewport_height, viewport_width);
true
}
pub(super) fn set_cursor_byte(state: &mut EditorState, target_byte: usize) {
let source_len = state.buffer.contents().len();
let clamped = target_byte.min(source_len);
let char_off = state.buffer.rope().byte_to_char(clamped);
state.cursor.offset = char_off.min(state.buffer.len_chars());
}
pub(super) fn cursor_byte(state: &EditorState) -> usize {
state.buffer.rope().char_to_byte(state.cursor.offset)
}
fn move_line_skipping_alignment(state: &mut EditorState, down: bool, viewport_width: usize) {
let visual = state.visual_line_nav;
state.move_cursor_line(down, visual, viewport_width);
}
pub(super) fn cursor_on_hidden_block(state: &EditorState) -> bool {
let rope = state.buffer.rope();
let cursor_byte = rope.char_to_byte(state.cursor.offset);
let Some(block_idx) = state.parsed.source_map.block_for_byte(cursor_byte) else {
return false;
};
if state.parsed.block_own_line_count(block_idx) > 0 {
return false;
}
let Some(range) = state.parsed.source_map.original_range_for_byte(cursor_byte) else {
return false;
};
let source = state.buffer.contents();
let end = range.end.min(source.len());
source[range.start..end].trim_start().starts_with("<!--")
}
fn snap_cursor_out_of_hidden_block(state: &mut EditorState, viewport_width: usize) {
let mut safety = 32usize;
while cursor_on_hidden_block(state) && safety > 0 {
let prev_offset = state.cursor.offset;
if state.visual_line_nav && viewport_width > 0 {
state.move_down_visual(viewport_width);
} else {
state.cursor.move_down(&state.buffer);
}
if state.cursor.offset == prev_offset {
break;
}
safety -= 1;
}
}
pub(super) fn apply_byte_delta(
state: &mut EditorState,
byte_delta: EditDelta,
cursor_byte_target: usize,
) {
let offset_char = state.buffer.rope().byte_to_char(byte_delta.offset);
let delta = EditDelta {
offset: offset_char,
removed: byte_delta.removed,
inserted: byte_delta.inserted,
};
state.apply_delta(delta);
let source = state.buffer.contents();
let clamped_byte = cursor_byte_target.min(source.len());
let char_off = state.buffer.rope().byte_to_char(clamped_byte);
state.cursor.offset = char_off.min(state.buffer.len_chars());
state.cursor.preferred_col = state.cursor.cell_col(&state.buffer);
state.update_cursor_block();
}
fn list_handle_newline(state: &mut EditorState) -> bool {
let Some((source, info)) = current_list(state) else {
return false;
};
let byte = cursor_byte(state);
let Some(item_idx) = list_edit::cursor_item_idx(&info, byte) else {
return false;
};
let item = &info.items[item_idx];
if byte < item.marker_end {
return false;
}
let result = if item.content_is_empty(&source) {
if list_edit::is_blank_line_above(&source, item.start) {
list_edit::exit_list(&info, &source, byte)
} else {
list_edit::space_out_empty_item(&info, &source, byte)
}
} else {
list_edit::continue_item(&info, &source, byte)
};
if let Some(res) = result {
apply_byte_delta(state, res.delta, res.cursor_byte);
true
} else {
false
}
}
fn list_indent(state: &mut EditorState) -> bool {
let Some((source, info)) = current_list(state) else {
return false;
};
let byte = cursor_byte(state);
if list_edit::cursor_item_idx(&info, byte) == Some(0) {
return true;
}
let Some(res) = list_edit::indent_item(&info, &source, byte, crate::constants::INDENT_WIDTH)
else {
return false;
};
apply_byte_delta(state, res.delta, res.cursor_byte);
true
}
fn list_outdent(state: &mut EditorState) -> bool {
let Some((source, info)) = current_list(state) else {
return false;
};
let byte = cursor_byte(state);
let Some(res) = list_edit::outdent_item(&info, &source, byte, crate::constants::INDENT_WIDTH)
else {
return false;
};
apply_byte_delta(state, res.delta, res.cursor_byte);
true
}
fn list_toggle_checkbox(state: &mut EditorState) {
let Some((source, info)) = current_list(state) else {
return;
};
let byte = cursor_byte(state);
let Some(res) = list_edit::toggle_checkbox(&info, &source, byte) else {
return;
};
apply_byte_delta(state, res.delta, res.cursor_byte);
}
pub(crate) fn list_renumber_at_cursor(state: &mut EditorState) {
if state.mode == Mode::Raw {
return;
}
let source = state.buffer.contents();
let byte_before = cursor_byte(state);
if let Some(delta) = list_edit::renumber_list_block(&source, byte_before) {
apply_byte_delta(state, delta, byte_before);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FixListNumbering {
NotOrdered,
AlreadyCorrect,
Fixed,
}
pub fn fix_list_numbering(
state: &mut EditorState,
viewport_height: usize,
viewport_width: usize,
) -> FixListNumbering {
let source = state.buffer.contents();
let byte = cursor_byte(state);
match list_edit::find_list_at(&source, byte).map(|info| info.kind) {
Some(list_edit::MarkerKind::Ordered(_)) => {}
_ => return FixListNumbering::NotOrdered,
}
match list_edit::renumber_list_block(&source, byte) {
Some(delta) => {
apply_byte_delta(state, delta, byte);
state.ensure_cursor_visible(viewport_height, viewport_width);
FixListNumbering::Fixed
}
None => FixListNumbering::AlreadyCorrect,
}
}
fn current_list(state: &EditorState) -> Option<(String, ListInfo)> {
if state.mode == Mode::Raw {
return None;
}
let source = state.buffer.contents();
let byte = cursor_byte(state);
list_edit::find_list_at(&source, byte).map(|info| (source, info))
}
fn list_backspace_consumes_marker(state: &mut EditorState) -> bool {
let Some((source, info)) = current_list(state) else {
return false;
};
let byte = cursor_byte(state);
let Some(item_idx) = list_edit::cursor_item_idx(&info, byte) else {
return false;
};
let item = &info.items[item_idx];
if byte != item.content_start {
return false;
}
if item.task.is_some() {
let removed = source[item.marker_end..item.content_start].to_owned();
if removed.is_empty() {
return false;
}
let delta = EditDelta {
offset: item.marker_end,
removed,
inserted: String::new(),
};
apply_byte_delta(state, delta, item.marker_end);
return true;
}
let delete_start = item.start;
let removed = source[delete_start..item.content_start].to_owned();
if removed.is_empty() {
return false;
}
let delta = EditDelta {
offset: delete_start,
removed,
inserted: String::new(),
};
apply_byte_delta(state, delta, delete_start);
true
}
fn clamp_cursor_out_of_marker(state: &mut EditorState) {
let Some((_, info)) = current_list(state) else {
return;
};
let byte = cursor_byte(state);
let Some(item_idx) = list_edit::cursor_item_idx(&info, byte) else {
return;
};
let item = &info.items[item_idx];
if byte >= item.start && byte < item.content_start {
set_cursor_byte(state, item.content_start);
}
}
fn list_move_horizontal(state: &mut EditorState, forward: bool) -> bool {
let Some((source, info)) = current_list(state) else {
return false;
};
let byte = cursor_byte(state);
let Some(item_idx) = list_edit::cursor_item_idx(&info, byte) else {
return false;
};
let item = &info.items[item_idx];
let has_continuation = item.end > item.line_end + 1;
if byte > item.line_end || (byte == item.line_end && has_continuation && forward) {
return false;
}
if forward {
if byte >= item.line_end {
if let Some(next) = info.items.get(item_idx + 1) {
set_cursor_byte(state, next.content_start);
return true;
}
set_cursor_byte(state, info.end.min(source.len()));
return true;
}
let new_char = next_grapheme_offset(&state.buffer, state.cursor.offset);
state.cursor.offset = new_char;
true
} else {
if byte <= item.content_start {
if item_idx > 0 {
let prev = &info.items[item_idx - 1];
set_cursor_byte(state, prev.line_end);
return true;
}
if info.start > 0 {
set_cursor_byte(state, info.start - 1);
}
return true;
}
let new_char = prev_grapheme_offset(&state.buffer, state.cursor.offset);
state.cursor.offset = new_char;
true
}
}
#[cfg(test)]
mod tests {
use super::paste_text;
use crate::config::Theme;
use crate::document::Buffer;
use crate::editor::{EditorState, Mode};
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
#[test]
fn paste_normalizes_crlf_to_lf() {
let mut state = EditorState::new(Buffer::from_str(""), theme());
state.mode = Mode::Raw;
paste_text(&mut state, "one\r\ntwo\r\n", 24, 80);
let contents = state.buffer.contents();
assert!(
!contents.contains('\r'),
"rope must hold no CR: {contents:?}"
);
assert_eq!(contents, "one\ntwo\n");
}
#[test]
fn paste_into_crlf_buffer_does_not_double_cr_on_save() {
let dir = tempfile::tempdir().expect("tempdir");
let src = dir.path().join("crlf.md");
std::fs::write(&src, "a\r\nb\r\n").expect("seed");
let mut state = EditorState::new(Buffer::load_file(&src).expect("load"), theme());
state.mode = Mode::Raw;
state.cursor.offset = 0;
paste_text(&mut state, "X\r\n", 24, 80);
let out = dir.path().join("out.md");
state.buffer.save_copy(&out).expect("save");
assert_eq!(std::fs::read(&out).expect("read"), b"X\r\na\r\nb\r\n");
}
}