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 => {
enter_edit_if_preview(state, viewport_height);
let text = clipboard_text(state);
if !text.is_empty() {
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,
});
} else {
insert_text(state, &text);
}
}
}
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) {
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;
}
}
if let Some(byte) = state
.parsed
.source_map
.original_byte_for_rendered_line(scroll)
{
let char_offset = state.buffer.rope().byte_to_char(byte);
state.cursor.offset = char_offset.min(state.buffer.len_chars());
}
}
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
}
const OS_CLIPBOARD: bool = !cfg!(test);
fn copy_to_clipboard(state: &mut EditorState, text: String) {
if OS_CLIPBOARD {
#[cfg(feature = "clipboard")]
copy_to_system_clipboard(text.clone());
osc52_copy(&text);
}
state.kill_ring = text;
}
#[cfg(all(feature = "clipboard", target_os = "linux"))]
fn copy_to_system_clipboard(text: String) {
use arboard::SetExtLinux;
std::thread::spawn(move || {
if let Ok(mut cb) = arboard::Clipboard::new() {
let _ = cb.set().wait().text(text);
}
});
}
#[cfg(all(feature = "clipboard", not(target_os = "linux")))]
fn copy_to_system_clipboard(text: String) {
if let Ok(mut cb) = arboard::Clipboard::new() {
let _ = cb.set_text(&text);
}
}
fn osc52_copy(text: &str) {
use std::io::Write;
let encoded = base64_encode(text.as_bytes());
let mut stdout = std::io::stdout();
let _ = write!(stdout, "\x1b]52;c;{encoded}\x07");
let _ = stdout.flush();
}
fn base64_encode(data: &[u8]) -> String {
const CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
out.push(CHARS[(b0 >> 2) as usize] as char);
out.push(CHARS[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
if chunk.len() > 1 {
out.push(CHARS[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(CHARS[(b2 & 0x3F) as usize] as char);
} else {
out.push('=');
}
}
out
}
pub fn clipboard_text(state: &EditorState) -> String {
#[cfg(feature = "clipboard")]
if OS_CLIPBOARD {
if let Ok(mut cb) = arboard::Clipboard::new() {
if let Ok(text) = cb.get_text() {
return text;
}
}
}
state.kill_ring.clone()
}
pub fn paste_text(
state: &mut EditorState,
text: &str,
viewport_height: usize,
viewport_width: usize,
) {
if text.is_empty() {
return;
}
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", 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", viewport_height, viewport_width)
}
fn insert_inline_snippet(
state: &mut EditorState,
prefix: &str,
text_placeholder: &str,
viewport_height: usize,
viewport_width: usize,
) -> bool {
if state.mode == Mode::Preview {
sync_cursor_to_scroll(state, viewport_height);
}
let wrap = state.selection.as_ref().and_then(|sel| {
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);
if text.contains('\n') {
return None;
}
Some((start, text))
});
let insert_at = wrap
.as_ref()
.map_or(state.cursor.offset, |(start, _)| *start);
if !block_allows_inline_markdown_at(state, insert_at) {
return false;
}
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 inserted = format!("{prefix}[{visible_text}]({URL_PLACEHOLDER})");
state.cursor.offset = offset;
state.apply_delta(EditDelta {
offset,
removed,
inserted,
});
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;
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::base64_encode;
#[test]
fn base64_encodes_empty() {
assert_eq!(base64_encode(b""), "");
}
#[test]
fn base64_encodes_one_byte() {
assert_eq!(base64_encode(b"f"), "Zg==");
}
#[test]
fn base64_encodes_two_bytes() {
assert_eq!(base64_encode(b"fo"), "Zm8=");
}
#[test]
fn base64_encodes_three_bytes() {
assert_eq!(base64_encode(b"foo"), "Zm9v");
}
#[test]
fn base64_encodes_rfc4648_vectors() {
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
assert_eq!(base64_encode(b"Hello, world!"), "SGVsbG8sIHdvcmxkIQ==");
}
}