diff --git a/docs/v0.6-markdown.md b/docs/v0.6-markdown.md index 1b9afc7..c262d4d 100644 --- a/docs/v0.6-markdown.md +++ b/docs/v0.6-markdown.md @@ -14,6 +14,8 @@ file format. - [x] Heading lines bolded in render (faux-bold double-strike) - [x] List continuation on Enter inside `- ` / `1. ` (with empty-item exit) +- [x] Blockquote continuation on Enter inside `> ` (nested depth preserved, + empty-quote exit); a nested list inside a quote degrades to `> ` - [x] Soft-wrap at word boundaries ## Snippets diff --git a/editor/src/editing.rs b/editor/src/editing.rs index 1ef82e2..e720e3d 100644 --- a/editor/src/editing.rs +++ b/editor/src/editing.rs @@ -101,18 +101,19 @@ impl Editor { true } - /// Enter in Insert mode, with Markdown list continuation. At the END of a - /// list line (`- `/`* `/`+ ` or `N. `), start the next item automatically — - /// same bullet, or the next number — preserving indentation. Enter on an - /// otherwise-empty item strips the marker instead (exits the list). Anywhere - /// else (mid-line, or a non-list line) it's a plain newline. + /// Enter in Insert mode, with Markdown list and blockquote continuation. At the + /// END of a list line (`- `/`* `/`+ ` or `N. `) or a blockquote (`> `, nested + /// depth preserved), start the next line automatically — same bullet, the next + /// number, or the same quote depth — preserving indentation. Enter on an + /// otherwise-empty item/quote strips the marker instead (exits it). Anywhere + /// else (mid-line, or a plain line) it's a plain newline. pub(crate) fn insert_newline(&mut self) { let le = self.line_end(self.caret); if self.caret == le { let ls = self.line_start(self.caret); - if let Some((next, cur_len, content_empty)) = list_marker(&self.text[ls..le]) { + if let Some((next, cur_len, content_empty)) = continuation_marker(&self.text[ls..le]) { if content_empty { - // Empty item: drop the marker, leaving a blank line. + // Empty item/quote: drop the marker, leaving a blank line. self.text.replace_range(ls..ls + cur_len, ""); self.caret = ls; } else { diff --git a/editor/src/markdown.rs b/editor/src/markdown.rs index db72056..2ef2b93 100644 --- a/editor/src/markdown.rs +++ b/editor/src/markdown.rs @@ -2,14 +2,15 @@ use super::*; -/// Parse a Markdown list marker at the start of `line`. Returns +/// Parse an auto-continued Markdown line prefix at the start of `line` — a list +/// item (`- `/`* `/`+ ` or `N. `) or a blockquote (`> `, possibly nested). Returns /// `(next_marker, current_marker_len, content_empty)` where `next_marker` is what -/// the following item should start with (same bullet, or the incremented number, -/// preserving indentation), `current_marker_len` is the byte length of this -/// line's marker prefix, and `content_empty` is whether anything follows it. -/// Returns `None` when the line isn't a list item. ASCII throughout (leading -/// spaces, bullets, digits, `. ` are all single-byte). -pub(crate) fn list_marker(line: &str) -> Option<(String, usize, bool)> { +/// the following line should start with (same bullet, the incremented number, or +/// the same quote depth, preserving indentation), `current_marker_len` is the byte +/// length of this line's marker prefix, and `content_empty` is whether anything +/// follows it. Returns `None` when the line has no such prefix. ASCII throughout +/// (leading spaces, bullets, digits, `. `, `> ` are all single-byte). +pub(crate) fn continuation_marker(line: &str) -> Option<(String, usize, bool)> { let indent = line.len() - line.trim_start_matches(' ').len(); let rest = &line[indent..]; for bullet in ["- ", "* ", "+ "] { @@ -27,6 +28,25 @@ pub(crate) fn list_marker(line: &str) -> Option<(String, usize, bool)> { let n: usize = rest[..digits].parse().unwrap_or(0); return Some((format!("{}{}. ", &line[..indent], n + 1), cur_len, content_empty)); } + // Blockquote: a run of `>` markers, each with an optional trailing space, + // continued at the same depth (`> > text` → `> > `). A bare `>` normalizes to + // `> `. A nested list inside the quote isn't preserved — it degrades to `> `. + if rest.starts_with('>') { + let bytes = rest.as_bytes(); + let mut j = 0; + let mut depth = 0; + while j < bytes.len() && bytes[j] == b'>' { + depth += 1; + j += 1; + if j < bytes.len() && bytes[j] == b' ' { + j += 1; + } + } + let cur_len = indent + j; + let content_empty = line[cur_len..].trim().is_empty(); + let next = format!("{}{}", &line[..indent], "> ".repeat(depth)); + return Some((next, cur_len, content_empty)); + } None } diff --git a/editor/src/tests.rs b/editor/src/tests.rs index 5373d67..8ebb197 100644 --- a/editor/src/tests.rs +++ b/editor/src/tests.rs @@ -75,6 +75,32 @@ fn enter_splits_the_line() { assert_eq!(e.caret, 4); } +#[test] +fn enter_in_a_blockquote_continues_the_marker() { + let mut e = typed("> quote"); + e.handle(Key::Enter); + e.handle(Key::Char('m')); + assert_eq!(e.text, "> quote\n> m"); + assert_eq!(e.caret, 11); +} + +#[test] +fn enter_on_an_empty_blockquote_exits_the_quote() { + let mut e = typed("> quote"); + e.handle(Key::Enter); // -> "> quote\n> " + e.handle(Key::Enter); // empty quote: drop the "> ", leaving a blank line + assert_eq!(e.text, "> quote\n"); + assert_eq!(e.caret, 8); +} + +#[test] +fn enter_in_a_nested_blockquote_keeps_the_depth() { + let mut e = typed("> > deep"); + e.handle(Key::Enter); + e.handle(Key::Char('x')); + assert_eq!(e.text, "> > deep\n> > x"); +} + #[test] fn escape_enters_normal_and_steps_onto_last_char() { let mut e = typed("abc");