documents: block commands as Typora's Paragraph menu
MarkdownDocument::block runs a BlockCommand at the caret: heading levels, paragraph, promote and demote; quote and list toggles; rule, code, math, toc, footnote, link definition and front matter inserts; empty paragraphs before and after; delete. Formats and block commands share one runner off the UI thread; FormatRefusal is now EditRefusal. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1d0354592b
commit
58e212d4ec
15 files changed
+1849
-188
No files matched your search
@@ -0,0 +1,270 @@
|
||||
//! Block commands as Typora's Paragraph menu (R4.7).
|
||||
|
||||
use gpui::{Modifiers, TestAppContext};
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::{open, opened, press, text};
|
||||
use crate::documents::{
|
||||
BlockCommand, EditRefusal, MarkdownMode, document::block::run, markdown::file_tree,
|
||||
};
|
||||
|
||||
/// `marked` after `command`: `‸` is the caret, `⟨…⟩` the selection.
|
||||
fn ran(marked: &str, command: BlockCommand) -> Result<String, EditRefusal> {
|
||||
let caret = marked.find('‸');
|
||||
let text: String = marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||
let selection = match caret {
|
||||
Some(at) => at..at,
|
||||
None => {
|
||||
let start = marked.find('⟨').expect("a selection");
|
||||
start..marked.find('⟩').expect("its end") - '⟨'.len_utf8()
|
||||
}
|
||||
};
|
||||
let (edits, selected) = run(&text, selection, command)?;
|
||||
let mut out = text.clone();
|
||||
for (range, with) in edits.into_iter().rev() {
|
||||
out.replace_range(range, &with);
|
||||
}
|
||||
Ok(match selected.is_empty() {
|
||||
true => format!("{}‸{}", &out[..selected.start], &out[selected.start..]),
|
||||
false => format!(
|
||||
"{}⟨{}⟩{}",
|
||||
&out[..selected.start],
|
||||
&out[selected.clone()],
|
||||
&out[selected.end..]
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn ok(marked: &str, command: BlockCommand) -> String {
|
||||
ran(marked, command).expect("ran")
|
||||
}
|
||||
|
||||
/// The top-level blocks `text` reads as.
|
||||
fn kinds(text: &str) -> Vec<&'static str> {
|
||||
let text: String = text.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
|
||||
let root = file_tree(&text);
|
||||
let kids = root.children().map(Vec::as_slice).unwrap_or_default();
|
||||
(kids.iter())
|
||||
.map(|kid| match kid {
|
||||
Node::Paragraph(_) => "p",
|
||||
Node::Heading(_) => "h",
|
||||
Node::Blockquote(_) => "quote",
|
||||
Node::List(_) => "list",
|
||||
Node::Code(_) => "code",
|
||||
Node::Math(_) => "math",
|
||||
Node::ThematicBreak(_) => "rule",
|
||||
Node::Yaml(_) => "yaml",
|
||||
Node::Definition(_) => "definition",
|
||||
Node::FootnoteDefinition(_) => "note",
|
||||
_ => "other",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_set_and_toggle_as_typora() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ok("a‸b", Heading(2)), "## a‸b");
|
||||
assert_eq!(ok("## a‸b", Heading(2)), "a‸b");
|
||||
assert_eq!(ok("## a‸b", Heading(3)), "### a‸b");
|
||||
assert_eq!(ok("a‸", Promote), "###### a‸");
|
||||
assert_eq!(ok("# a‸", Promote), "# a‸");
|
||||
assert_eq!(ok("## a‸", Promote), "# a‸");
|
||||
assert_eq!(ok("###### a‸", Demote), "a‸");
|
||||
assert_eq!(ok("a‸", Demote), "a‸");
|
||||
assert_eq!(ok("a\n\n‸\n\nb", Heading(2)), "a\n\n## ‸\n\nb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_line_becomes_the_heading_alone() {
|
||||
let out = ok("a\nb‸\nc", BlockCommand::Heading(1));
|
||||
assert_eq!(out, "a\n# b‸\nc");
|
||||
assert_eq!(kinds(&out), ["p", "h", "p"]);
|
||||
assert_eq!(ok("> a\nb‸", BlockCommand::Heading(1)), "> a\n> # b‸");
|
||||
assert_eq!(ok("> a‸\nb", BlockCommand::Heading(1)), "> # a‸\n> b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setext_headings_relevel_and_go_atx_past_two() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ok("a‸\n===", Heading(2)), "a‸\n---");
|
||||
assert_eq!(ok("a‸\n===", Heading(3)), "### a‸");
|
||||
assert_eq!(ok("a\nb‸\n---", Heading(4)), "#### a b‸");
|
||||
assert_eq!(ok("a‸\n===", Paragraph), "a‸");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_heading_made_text_keeps_apart_from_its_neighbors() {
|
||||
use BlockCommand::Paragraph;
|
||||
assert_eq!(ok("a\n# h‸", Paragraph), "a\n\nh‸");
|
||||
assert_eq!(ok("# h‸\nb", Paragraph), "h‸\n\nb");
|
||||
let out = ok("# h‸\n---", Paragraph);
|
||||
assert_eq!(kinds(&out), ["p", "rule"]);
|
||||
assert_eq!(ok("- a\n- # h‸", Paragraph), "- a\n- h‸");
|
||||
let out = ok("> # h‸\nb", Paragraph);
|
||||
assert_eq!(kinds(&out), ["quote", "p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_commands_refuse_or_add_a_paragraph_as_typora() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ran("```\na‸\n```", Heading(1)), Err(EditRefusal::Code));
|
||||
assert_eq!(ran("$$\na‸\n$$", Promote), Err(EditRefusal::Math));
|
||||
assert_eq!(ok("$$\na‸\n$$", Heading(1)), "$$\na\n$$\n\n‸");
|
||||
assert_eq!(
|
||||
ran("---\nt: a‸\n---\n", Heading(1)),
|
||||
Err(EditRefusal::FrontMatter)
|
||||
);
|
||||
assert_eq!(ran("- [ ] a‸", Heading(1)), Err(EditRefusal::Outside));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_wrap_and_unwrap() {
|
||||
use BlockCommand::Quote;
|
||||
assert_eq!(ok("a‸", Quote), "> a‸");
|
||||
assert_eq!(ok("> a‸", Quote), "a‸");
|
||||
assert_eq!(ok("> a\n> b‸", Quote), "a\nb‸");
|
||||
assert_eq!(ok("> a\nb‸", Quote), "a\nb‸");
|
||||
assert_eq!(ok("⟨a\n\nb⟩", Quote), "> ⟨a\n>\n> b⟩");
|
||||
assert_eq!(ok("x\n> a‸", Quote), "x\n\na‸");
|
||||
assert_eq!(ok("> [!NOTE]\n> a‸", Quote), "a‸");
|
||||
assert_eq!(ok("- a‸", Quote), "- > a‸");
|
||||
assert_eq!(ok("> a\n>\n> ⟨b⟩", Quote), "> a\n>\n> > ⟨b⟩");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_wrap_each_line() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ok("a‸", BulletList), "- a‸");
|
||||
assert_eq!(ok("a‸", OrderedList), "1. a‸");
|
||||
assert_eq!(ok("a‸", TaskList), "- [ ] a‸");
|
||||
assert_eq!(ok("⟨a\nb⟩", OrderedList), "1. ⟨a\n2. b⟩");
|
||||
assert_eq!(ok("> a‸", BulletList), "> - a‸");
|
||||
assert_eq!(ok("- a\n\n b‸", BulletList), "- a\n\n - b‸");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_of_the_kind_unwraps_and_another_retypes() {
|
||||
use BlockCommand::*;
|
||||
let out = ok("- a\n- b‸", BulletList);
|
||||
assert_eq!(out, "a\n\nb‸");
|
||||
assert_eq!(kinds(&out), ["p", "p"]);
|
||||
assert_eq!(ok("- a\n- b‸", OrderedList), "1. a\n2. b‸");
|
||||
assert_eq!(ok("- a\n b‸", OrderedList), "1. a\n b‸");
|
||||
assert_eq!(ok("- [ ] a‸", BulletList), "- a‸");
|
||||
assert_eq!(ok("* a‸", TaskList), "* [ ] a‸");
|
||||
assert_eq!(ok("3. a\n4. b‸", BulletList), "- a\n- b‸");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserts_split_the_paragraph_at_the_caret() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ok("ab‸cd", Rule), "ab\n\n---\n\n‸cd");
|
||||
assert_eq!(ok("ab‸", CodeBlock), "ab\n\n```\n‸\n```");
|
||||
assert_eq!(ok("a ⟨b⟩", CodeBlock), "a\n\n```\n‸b\n```");
|
||||
assert_eq!(ok("‸ab", Toc), "[toc]‸\n\nab");
|
||||
assert_eq!(ok("# ab‸cd", Rule), "# ab\n\n---\n\n# ‸cd");
|
||||
assert_eq!(ok("- ab‸", Rule), "- ab\n\n ---\n\n ‸");
|
||||
assert_eq!(ok("```\na‸\n```", Rule), "```\na\n```\n\n---\n\n‸");
|
||||
assert_eq!(ok("a\n\n‸\n\nb", Rule), "a\n\n---\n\n‸\n\nb");
|
||||
assert_eq!(ran("⟨a\n\nb⟩", Rule), Err(EditRefusal::Across));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_and_definitions_take_free_names() {
|
||||
use BlockCommand::*;
|
||||
let out = ok("x[^1]\n\n[^1]: y\n\nab‸", Footnote);
|
||||
assert_eq!(out, "x[^1]\n\n[^1]: y\n\nab\n\n[^2]: ‸");
|
||||
assert_eq!(ok("‸", LinkDefinition), "[‸]: ");
|
||||
assert_eq!(ok("⟨see⟩", LinkDefinition), "[see]: ‸");
|
||||
assert_eq!(ok("⟨a/b.md⟩", LinkDefinition), "[‸]: a/b.md");
|
||||
let out = ok("ab‸", MathBlock);
|
||||
assert_eq!(kinds(&out), ["p", "math"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_paragraphs_come_before_and_after() {
|
||||
use BlockCommand::*;
|
||||
assert_eq!(ok("a‸", ParagraphAfter), "a\n\n‸");
|
||||
assert_eq!(ok("# a‸\nb", ParagraphAfter), "# a\n\n‸\n\nb");
|
||||
assert_eq!(ok("a\n\nb‸", ParagraphBefore), "a\n\n‸\n\nb");
|
||||
assert_eq!(ok("a\n# h‸", ParagraphBefore), "a\n\n‸\n\n# h");
|
||||
assert_eq!(ok("- a‸", ParagraphBefore), "- ‸\n\n a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_matter_goes_on_top_once() {
|
||||
let out = ok("a‸", BlockCommand::FrontMatter);
|
||||
assert_eq!(out, "---\n‸\n---\n\na");
|
||||
assert_eq!(kinds(&out), ["yaml", "p"]);
|
||||
assert_eq!(
|
||||
ran("---\nt: 1\n---\n\na‸", BlockCommand::FrontMatter),
|
||||
Err(EditRefusal::Present)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_takes_the_block_or_its_lone_holder() {
|
||||
use BlockCommand::Delete;
|
||||
assert_eq!(ok("a\n\nb‸\n\nc", Delete), "a\n\n‸c");
|
||||
assert_eq!(ok("x\n\n> b‸\n\nc", Delete), "x\n\n‸c");
|
||||
assert_eq!(ok("- a\n- b‸\n- c", Delete), "- a\n‸- c");
|
||||
assert_eq!(ok("- a‸\n\n b", Delete), "- ‸\n\n b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_endings_follow_the_text() {
|
||||
assert_eq!(
|
||||
ok("ab‸\r\n", BlockCommand::ParagraphAfter),
|
||||
"ab\r\n\r\n‸\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_source_command_is_one_undo_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("alpha\n", MarkdownMode::Source, cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(2..2), cx));
|
||||
document.block(BlockCommand::Heading(2), cx).expect("taken");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "## alpha\n");
|
||||
document.update(cx, |document, cx| {
|
||||
document.editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
});
|
||||
assert_eq!(text(&document, cx), "alpha\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_block_takes_a_command_and_stays_open(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("alpha\n", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
document.update(cx, |document, cx| {
|
||||
document.block(BlockCommand::Quote, cx).expect("taken");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(opened(&document, cx), "> alpha", "still open to type in");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_command_past_the_open_block_writes_it_back_first(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("first\n\nalpha\n", MarkdownMode::Visual, cx);
|
||||
press(1, Modifiers::none(), cx);
|
||||
document.update(cx, |document, cx| {
|
||||
let open = document.open.as_ref().expect("opened");
|
||||
open.input.update(cx, |input, cx| input.insert("x", cx));
|
||||
document
|
||||
.block(BlockCommand::FrontMatter, cx)
|
||||
.expect("taken");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let written = text(&document, cx);
|
||||
assert!(written.starts_with("---\n\n---\n"), "{written:?}");
|
||||
assert!(
|
||||
written.contains("alpha") && written.contains('x'),
|
||||
"{written:?}"
|
||||
);
|
||||
document.read_with(cx, |document, _| assert!(document.open.is_none()));
|
||||
}
|
||||
Reference in new issue
Block a user