feat(editor): continue blockquote markers on Enter

Enter at the end of a `> ` line now starts the next line with `> `,
mirroring list continuation. Nested `> > ` keeps its depth, and Enter on
an empty quote drops the marker to exit. Generalises `list_marker` to
`continuation_marker`.
This commit is contained in:
Julien Calixte
2026-07-14 13:17:36 +02:00
parent 4ead64bada
commit b5c0c90dcc
4 changed files with 63 additions and 14 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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");