use super::markdown;
use super::rope_buffer::{CursorMove, RopeBuffer};
use crate::keys::action_shortcuts::TextAction;
pub fn surround_pair(c: char) -> Option<(&'static str, &'static str)> {
match c {
'(' => Some(("(", ")")),
'[' => Some(("[", "]")),
'{' => Some(("{", "}")),
'<' => Some(("<", ">")),
'"' => Some(("\"", "\"")),
'\'' => Some(("'", "'")),
'`' => Some(("`", "`")),
'*' => Some(("*", "*")),
'_' => Some(("_", "_")),
'~' => Some(("~", "~")),
_ => None,
}
}
pub fn emphasis_marker(action: TextAction) -> Option<&'static str> {
match action {
TextAction::Bold => Some("**"),
TextAction::Italic => Some("*"),
TextAction::Strikethrough => Some("~~"),
_ => None,
}
}
pub fn wrap_selection(buf: &mut RopeBuffer, open: &str, close: &str) -> bool {
let Some(((sr, sc), (er, ec))) = buf.selection_range() else {
return false;
};
let Some(text) = buf.selection_text() else {
return false;
};
buf.insert_str(format!("{open}{text}{close}"));
let shift = open.chars().count();
let inner_end_col = if sr == er { ec + shift } else { ec };
buf.set_selection((sr, sc + shift), (er, inner_end_col));
true
}
pub fn insert_pair(buf: &mut RopeBuffer, open: &str, close: &str) {
buf.insert_str(format!("{open}{close}"));
for _ in 0..close.chars().count() {
buf.move_cursor(CursorMove::Back);
}
}
pub fn smart_enter(buf: &mut RopeBuffer) -> bool {
enum Action {
ClearRow { chars: usize },
InsertPrefix(String),
Dedent,
}
if buf
.selection_range()
.is_some_and(|(start, end)| start != end)
{
return false;
}
let (row, action) = {
let (row, col) = buf.cursor();
let Some(line) = buf.row(row) else {
return false;
};
let total_chars = line.chars().count();
if col != total_chars {
return false;
}
let ws_end = markdown::leading_ws_byte_len(&line);
let (ws, after_ws) = line.split_at(ws_end);
let action = if let Some(marker_len) = markdown::list_marker_len(after_ws) {
if after_ws.len() == marker_len {
if ws_end > 0 {
Action::Dedent
} else {
Action::ClearRow { chars: total_chars }
}
} else {
let marker = &after_ws[..marker_len];
let next = increment_ordered_marker(marker).unwrap_or_else(|| marker.to_string());
Action::InsertPrefix(format!("{ws}{next}"))
}
} else if ws_end > 0 && total_chars == ws_end {
Action::Dedent
} else if ws_end > 0 {
Action::InsertPrefix(ws.to_string())
} else {
return false;
};
(row, action)
};
match action {
Action::Dedent => return buf.indent_rows(row..=row, true),
Action::ClearRow { chars } => {
buf.move_cursor(CursorMove::Head);
buf.delete_str(chars);
}
Action::InsertPrefix(prefix) => {
buf.edit(|buf| {
buf.insert_newline();
buf.insert_str(prefix);
});
}
}
true
}
pub fn jump_to_heading(buf: &mut RopeBuffer, heading: &str) -> bool {
let row = (0..buf.row_count()).find(|&row| {
buf.row(row).is_some_and(|line| {
kimun_core::note::scan::heading_display_text(&line).as_deref() == Some(heading)
})
});
match row {
Some(row) => {
buf.cancel_selection();
buf.jump_to(row, 0)
}
None => false,
}
}
fn increment_ordered_marker(marker: &str) -> Option<String> {
let trimmed = marker.trim_end_matches(' ');
let dot = trimmed.strip_suffix('.')?;
let n: u32 = dot.parse().ok()?;
Some(format!("{}. ", n + 1))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ropetext::Text;
fn buffer(text: &str) -> RopeBuffer {
RopeBuffer::new(Text::from(text))
}
fn at_end(text: &str) -> RopeBuffer {
let mut buf = buffer(text);
buf.move_cursor(CursorMove::Bottom);
buf.move_cursor(CursorMove::End);
buf
}
fn text(buf: &RopeBuffer) -> String {
buf.text().to_string()
}
#[test]
fn surround_pair_maps_open_and_symmetric_chars() {
assert_eq!(surround_pair('('), Some(("(", ")")));
assert_eq!(surround_pair('['), Some(("[", "]")));
assert_eq!(surround_pair('{'), Some(("{", "}")));
assert_eq!(surround_pair('<'), Some(("<", ">")));
assert_eq!(surround_pair('"'), Some(("\"", "\"")));
assert_eq!(surround_pair('\''), Some(("'", "'")));
assert_eq!(surround_pair('`'), Some(("`", "`")));
assert_eq!(surround_pair('*'), Some(("*", "*")));
assert_eq!(surround_pair('_'), Some(("_", "_")));
assert_eq!(surround_pair('~'), Some(("~", "~")));
assert_eq!(surround_pair(')'), None);
assert_eq!(surround_pair(']'), None);
assert_eq!(surround_pair('}'), None);
assert_eq!(surround_pair('>'), None);
assert_eq!(surround_pair('a'), None);
}
#[test]
fn emphasis_marker_covers_bold_italic_and_strikethrough_only() {
assert_eq!(emphasis_marker(TextAction::Bold), Some("**"));
assert_eq!(emphasis_marker(TextAction::Italic), Some("*"));
assert_eq!(emphasis_marker(TextAction::Strikethrough), Some("~~"));
assert_eq!(emphasis_marker(TextAction::Underline), None);
assert_eq!(emphasis_marker(TextAction::Link), None);
assert_eq!(emphasis_marker(TextAction::Image), None);
assert_eq!(emphasis_marker(TextAction::ToggleHeader), None);
assert_eq!(emphasis_marker(TextAction::Header(1)), None);
}
#[test]
fn wrap_reselects_the_inner_text_so_wraps_chain() {
let mut buf = buffer("my note");
assert!(buf.set_selection((0, 0), (0, 7)));
assert!(wrap_selection(&mut buf, "[", "]"));
assert_eq!(text(&buf), "[my note]");
assert_eq!(buf.selection_range(), Some(((0, 1), (0, 8))));
assert!(wrap_selection(&mut buf, "[", "]"));
assert_eq!(text(&buf), "[[my note]]");
assert_eq!(buf.selection_range(), Some(((0, 2), (0, 9))));
}
#[test]
fn wrap_spans_a_multi_row_selection() {
let mut buf = buffer("abc\ndef");
assert!(buf.set_selection((0, 0), (1, 3)));
assert!(wrap_selection(&mut buf, "(", ")"));
assert_eq!(text(&buf), "(abc\ndef)");
assert_eq!(buf.selection_range(), Some(((0, 1), (1, 3))));
}
#[test]
fn wrap_counts_columns_in_chars() {
let mut buf = buffer("héllo🦀 x");
assert!(buf.set_selection((0, 0), (0, 6))); assert!(wrap_selection(&mut buf, "`", "`"));
assert_eq!(text(&buf), "`héllo🦀` x");
assert_eq!(buf.selection_range(), Some(((0, 1), (0, 7))));
}
#[test]
fn wrap_handles_a_selection_made_right_to_left() {
let mut buf = buffer("hello world");
assert!(buf.jump_to(0, 5));
buf.start_selection();
assert!(buf.jump_to(0, 0));
assert!(wrap_selection(&mut buf, "(", ")"));
assert_eq!(text(&buf), "(hello) world");
assert_eq!(buf.selection_range(), Some(((0, 1), (0, 6))));
}
#[test]
fn wrap_uses_a_word_selection_as_it_stands() {
let mut buf = buffer("hello world");
buf.move_cursor(CursorMove::Head);
buf.start_selection();
buf.move_cursor(CursorMove::WordForward);
assert!(wrap_selection(&mut buf, "~~", "~~"));
assert_eq!(text(&buf), "~~hello ~~world");
}
#[test]
fn wrap_handles_a_non_ascii_selection() {
let mut buf = buffer("hello 你好 world");
buf.move_cursor(CursorMove::Head);
buf.move_cursor(CursorMove::WordForward);
buf.start_selection();
buf.move_cursor(CursorMove::WordForward);
assert!(wrap_selection(&mut buf, "**", "**"));
assert_eq!(text(&buf), "hello **你好 **world");
}
#[test]
fn wrap_without_a_selection_touches_nothing() {
let mut buf = at_end("hello");
assert!(!wrap_selection(&mut buf, "(", ")"));
assert_eq!(text(&buf), "hello");
assert!(!buf.take_outcome().changed);
}
#[test]
fn a_zero_width_selection_does_not_wrap() {
let mut buf = buffer("hello");
assert!(buf.jump_to(0, 2));
buf.start_selection();
assert!(!wrap_selection(&mut buf, "(", ")"));
assert_eq!(text(&buf), "hello");
}
#[test]
fn a_wrap_is_one_undo_group() {
let mut buf = buffer("hello world");
assert!(buf.set_selection((0, 0), (0, 5)));
assert!(wrap_selection(&mut buf, "(", ")"));
assert_eq!(text(&buf), "(hello) world");
assert!(buf.undo(), "the wrap is one entry");
assert_eq!(text(&buf), "hello world");
assert!(!buf.undo(), "and has no second half left to take back");
}
#[test]
fn insert_pair_leaves_the_cursor_between_the_markers() {
let mut buf = at_end("hello");
insert_pair(&mut buf, "**", "**");
assert_eq!(text(&buf), "hello****");
assert_eq!(buf.cursor(), (0, 7));
}
#[test]
fn insert_pair_with_a_single_char_marker() {
let mut buf = buffer("");
insert_pair(&mut buf, "*", "*");
assert_eq!(text(&buf), "**");
assert_eq!(buf.cursor(), (0, 1));
}
#[test]
fn smart_enter_continues_an_unordered_list() {
let mut buf = at_end("- foo");
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), "- foo\n- ");
}
#[test]
fn smart_enter_increments_an_ordered_list() {
let mut buf = at_end("1. foo");
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), "1. foo\n2. ");
}
#[test]
fn smart_enter_continues_an_indented_list() {
let mut buf = at_end(" - foo");
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), " - foo\n - ");
}
#[test]
fn smart_enter_continues_a_list_with_non_ascii_content() {
let mut buf = at_end("- 你好");
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), "- 你好\n- ");
}
#[test]
fn smart_enter_on_an_empty_list_marker_clears_the_row() {
let mut buf = at_end("- ");
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), "");
}
#[test]
fn smart_enter_on_an_empty_indented_list_marker_dedents_keeping_the_marker() {
let mut buf = at_end(" - ");
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), "- ");
}
#[test]
fn smart_enter_clears_an_empty_list_marker_once_fully_dedented() {
let mut buf = at_end(" - ");
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), "- ");
buf.move_cursor(CursorMove::End);
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), "");
}
#[test]
fn smart_enter_carries_the_indent() {
let mut buf = at_end(" body");
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), " body\n ");
}
#[test]
fn smart_enter_carries_an_existing_tab_indent() {
let mut buf = at_end("\tbody");
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), "\tbody\n\t");
}
#[test]
fn smart_enter_on_an_indent_only_row_dedents() {
let mut buf = at_end(" ");
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), "");
}
#[test]
fn smart_enter_on_a_tab_only_row_dedents_one_tab() {
let mut buf = at_end("\t\t");
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), "\t");
}
#[test]
fn smart_enter_declines_a_plain_row() {
let mut buf = at_end("plain");
assert!(!smart_enter(&mut buf));
assert_eq!(text(&buf), "plain");
}
#[test]
fn smart_enter_declines_mid_row() {
let mut buf = buffer("- foo");
assert!(buf.jump_to(0, 2));
assert!(!smart_enter(&mut buf));
assert_eq!(text(&buf), "- foo");
}
#[test]
fn smart_enter_declines_a_selection_of_width() {
let mut buf = at_end("- foo");
assert!(buf.set_selection((0, 0), (0, 2)));
assert!(!smart_enter(&mut buf));
assert_eq!(text(&buf), "- foo");
}
#[test]
fn a_zero_width_selection_does_not_stop_smart_enter() {
let mut buf = at_end("- foo");
buf.start_selection();
assert!(smart_enter(&mut buf));
assert_eq!(text(&buf), "- foo\n- ");
}
#[test]
fn continuing_a_list_is_one_undo_group() {
let mut buf = at_end("- foo");
assert!(smart_enter(&mut buf));
assert!(buf.undo(), "newline plus marker is one entry");
assert_eq!(text(&buf), "- foo");
assert!(!buf.undo());
}
#[test]
fn jump_to_heading_finds_any_level_and_normalises_markup() {
let mut buf = buffer("intro\n# Top\nbody\n## **Sub** One ##\nmore\n");
assert!(jump_to_heading(&mut buf, "Sub One"));
assert_eq!(buf.cursor(), (3, 0));
assert!(jump_to_heading(&mut buf, "Top"));
assert_eq!(buf.cursor(), (1, 0));
}
#[test]
fn an_unknown_heading_leaves_the_cursor_where_it_was() {
let mut buf = buffer("intro\n# Top\nbody");
assert!(buf.jump_to(2, 1));
assert!(!jump_to_heading(&mut buf, "Nope"));
assert_eq!(buf.cursor(), (2, 1));
}
#[test]
fn jump_to_heading_drops_a_live_selection_instead_of_extending_it() {
let mut buf = buffer("intro\n# Top\nbody");
assert!(buf.jump_to(2, 1));
buf.start_selection(); assert!(jump_to_heading(&mut buf, "Top"));
assert_eq!(buf.cursor(), (1, 0));
assert_eq!(buf.selection_range(), None);
}
#[test]
fn a_hash_inside_a_row_is_not_a_heading() {
let mut buf = buffer("see #tag here\n# Real");
assert!(jump_to_heading(&mut buf, "Real"));
assert_eq!(buf.cursor(), (1, 0));
assert!(!jump_to_heading(&mut buf, "tag here"));
}
#[test]
fn jump_to_heading_finds_headings_holding_links_and_tags() {
let mut buf =
buffer("# See [[other note]]\na\n# Sprint #42\nb\n# Docs [here](https://x.y)\nc\n");
assert!(jump_to_heading(&mut buf, "Docs here"));
assert_eq!(buf.cursor(), (4, 0));
assert!(jump_to_heading(&mut buf, "Sprint 42"));
assert_eq!(buf.cursor(), (2, 0));
assert!(jump_to_heading(&mut buf, "See other note"));
assert_eq!(buf.cursor(), (0, 0));
assert!(
!jump_to_heading(&mut buf, "See [[other note]]"),
"raw source is not what the OUTLINE shows"
);
}
}