diff --git a/AGENTS.md b/AGENTS.md index 85c402a..45e93c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window. - Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32. - Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation. -- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `
` breaks as `
` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of a paragraph's lines: 40,000 lines take 0.9 s). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the list keeps the top line, one between visual and preview keeps the list's scroll as it is. A block opens only when the read is of the text as it is now. A pressed block keeps its identity across the write-back of the one before it, and opens with the caret on the pressed character: each drawn piece keeps its written span and how it decodes (Markdown's escapes and references for text, as markdown-rs decodes them, autolink text as written, HTML's references for an HTML block's runs, none for code spans but a table cell's `\|`, cut as CommonMark cuts their end spaces), and is matched from its end, an equal character first, a drawn space next against a line end, decoded or not, so quote marks and indents at a line's start are never taken for text and an alert's text reads past its marker; text before an emoji lands at its piece's start; code, front matter, a link definition and an HTML block's runs read back line for line, LF, CRLF and CR alike, each drawn line ending its written one, a tab cut to spaces landing on the tab; a block's drawn positions count from its own start, so an edit before it keeps it alike. A secondary press opens nothing, an empty document's open on focus included: it writes back an open block, refuses the press if that changed the pressed block's kind, text or drawing (a definition's new address too), moves the caret there and emits `DocumentEvent::Pointed`, whose `DocumentTarget` names the block's kind, a link's or picture's address, and whether it lies in a table or code. `MarkdownDocument::format` toggles an `InlineFormat` at the selection as Typora's toggle does: inside that style it comes off; a selection is wrapped, its end spaces left out and the same style it partly covers taken in; a caret takes its word, or an empty pair between stop characters or on a blank line; `Clear` takes every inline mark off the blocks it touches, inline math's `$`, footnote brackets and escape backslashes too; code, math, HTML and front matter refuse, as does an end inside a node's marks or a link address, or a selection crossing a block, a link or another mark (`FormatRefusal`); past 128 KiB the toggle reads off the UI thread and lands only if text and selection are unchanged, and past the largest read it refuses; in an open block it edits the field, which stays open, and joins its one write-back step. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. +- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `
` breaks as `
` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 16 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the list keeps the top line, one between visual and preview keeps the list's scroll as it is. A block opens only when the read is of the text as it is now. A pressed block keeps its identity across the write-back of the one before it, and opens with the caret on the pressed character: each drawn piece keeps its written span and how it decodes (Markdown's escapes and references for text, as markdown-rs decodes them, autolink text as written, HTML's references for an HTML block's runs, none for code spans but a table cell's `\|`, cut as CommonMark cuts their end spaces), and is matched from its end, an equal character first, a drawn space next against a line end, decoded or not, so quote marks and indents at a line's start are never taken for text and an alert's text reads past its marker; text before an emoji lands at its piece's start; code, front matter, a link definition and an HTML block's runs read back line for line, LF, CRLF and CR alike, each drawn line ending its written one, a tab cut to spaces landing on the tab; a block's drawn positions count from its own start, so an edit before it keeps it alike. A secondary press opens nothing, an empty document's open on focus included: it writes back an open block, refuses the press if that changed the pressed block's kind, text or drawing (a definition's new address too), moves the caret there and emits `DocumentEvent::Pointed`, whose `DocumentTarget` names the block's kind, a link's or picture's address, and whether it lies in a table or code. `MarkdownDocument::format` toggles an `InlineFormat` at the selection as Typora's toggle does: inside that style it comes off; a selection is wrapped, its end spaces left out and the same style it partly covers taken in; a caret takes its word, or an empty pair between stop characters or on a blank line; `Clear` takes every inline mark off the blocks it touches, inline math's `$`, footnote brackets and escape backslashes too, an autolink's kept as written; code, math, HTML and front matter refuse, as does an end inside a node's marks or a link address, or a selection crossing a block, a link or another mark (`FormatRefusal`); past 16 KiB the toggle reads off the UI thread, one at a time (another refuses as `Busy`), and lands only if text and selection are unchanged and preview is not shown, a refusal met there coming as `DocumentEvent::FormatRefused`; past the largest read it refuses; in an open block it edits the field, which stays open, and joins its one write-back step. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. - Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles. - Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame. - Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow. diff --git a/src/documents/document/format.rs b/src/documents/document/format.rs index 4d043bd..d1d2da6 100644 --- a/src/documents/document/format.rs +++ b/src/documents/document/format.rs @@ -41,6 +41,10 @@ pub enum FormatRefusal { Across, /// Past the largest read: source alone. TooLong, + /// A long format still runs. + Busy, + /// The text changed before a long format finished. + Changed, } /// The edits and the selection after them. @@ -60,7 +64,7 @@ fn content(node: &Node) -> Range { } } -/// Room for text: a cell inside its pipes, end spaces too. +/// Room for text: inside a cell's pipes, trailing spaces too. fn room(node: &Node, text: &str) -> Range { let inner = match node { Node::TableCell(_) => { @@ -76,6 +80,12 @@ fn room(node: &Node, text: &str) -> Range { inner.start..inner.end + spaces } +/// Which of `sorted`, disjoint spans, holds `inner`. +pub(super) fn holder(sorted: &[Range], inner: &Range) -> Option { + let at = sorted.partition_point(|span| span.start <= inner.start); + (at > 0 && inner.end <= sorted[at - 1].end).then(|| at - 1) +} + fn block(node: &Node) -> bool { matches!( node, @@ -151,11 +161,12 @@ fn verbatim( selection: &Range, format: InlineFormat, ) -> Result<(), FormatRefusal> { - let blocks: Vec> = nodes + let mut blocks: Vec> = nodes .iter() .filter(|node| block(node)) .map(|node| span(node)) .collect(); + blocks.sort_by_key(|block| block.start); for node in nodes { let whole = span(node); let touches = |edge: bool| { @@ -166,9 +177,6 @@ fn verbatim( false => whole.start < at && at < whole.end, }) }; - let inline = blocks - .iter() - .any(|block| block.start <= whole.start && whole.end <= block.end); // Clear takes a block's inline code and tags too. let held = format != InlineFormat::Clear && touches(false); let why = match node { @@ -177,7 +185,11 @@ fn verbatim( Node::Yaml(_) | Node::Toml(_) if touches(true) => FormatRefusal::FrontMatter, Node::InlineCode(_) if format != InlineFormat::Code && held => FormatRefusal::Code, Node::InlineMath(_) if held => FormatRefusal::Math, - Node::Html(_) if (inline && held) || (!inline && touches(true)) => FormatRefusal::Html, + Node::Html(_) => match holder(&blocks, &whole).is_some() { + true if held => FormatRefusal::Html, + false if touches(true) => FormatRefusal::Html, + _ => continue, + }, _ => continue, }; return Err(why); @@ -222,9 +234,18 @@ pub(super) fn toggle( selection: Range, format: InlineFormat, ) -> Result { - let root = file_tree(text); + toggle_tree(text, &file_tree(text), selection, format) +} + +/// `toggle` on `root`, the tree `text` parses to. +pub(super) fn toggle_tree( + text: &str, + root: &Node, + selection: Range, + format: InlineFormat, +) -> Result { let mut nodes = Vec::new(); - walk(&root, &mut nodes); + walk(root, &mut nodes); verbatim(&nodes, &selection, format)?; if format == InlineFormat::Clear { return clear::clear(text, &nodes, selection); @@ -248,6 +269,7 @@ pub(super) fn toggle( .filter(|node| styled(format, node) && holds(node)) .min_by_key(|node| span(node).len()); if let Some(node) = found { + crossed(text, &nodes, &span(node), s, e, format)?; return Ok(unwrap( span(node), marks(node, text).expect("a styled node has marks"), @@ -283,6 +305,7 @@ pub(super) fn toggle( if format == InlineFormat::Underline && let Some(tags) = underline(&nodes, &within, s, e) { + crossed(text, &nodes, &(tags[0].start..tags[1].end), s, e, format)?; return Ok(unwrap(tags[0].start..tags[1].end, tags)); } if s != e { diff --git a/src/documents/document/format/apply.rs b/src/documents/document/format/apply.rs index 7f8e5dc..e99b9a0 100644 --- a/src/documents/document/format/apply.rs +++ b/src/documents/document/format/apply.rs @@ -7,7 +7,7 @@ use gpui::{AppContext as _, Context}; use super::{Change, FormatRefusal, InlineFormat, toggle}; use crate::documents::{ MarkdownMode, - document::{AT_ONCE, LARGEST_READ, MarkdownDocument}, + document::{AT_ONCE, DocumentEvent, LARGEST_READ, MarkdownDocument}, modes::joint, }; @@ -15,9 +15,7 @@ use crate::documents::{ type Snapshot = (String, usize, Range); impl MarkdownDocument { - /// `format` at the selection, Typora's way, or why not. - /// - /// Long texts toggle off the UI thread; refusals then log. + /// `format` at the selection; long texts refuse by event. pub fn format( &mut self, format: InlineFormat, @@ -34,6 +32,10 @@ impl MarkdownDocument { ); return Err(FormatRefusal::TooLong); } + if self.formatting { + log::info!("markdown document: format {format:?} refused: one runs"); + return Err(FormatRefusal::Busy); + } if full.len() <= AT_ONCE { let change = toggle(&full, selection, format).inspect_err(|why| { log::info!("markdown document: format {format:?} refused: {why:?}"); @@ -45,26 +47,31 @@ impl MarkdownDocument { full.len() ); let taken = (full.clone(), offset, selection.clone()); - self._formatting = Some(cx.spawn(async move |document, cx| { + self.formatting = true; + cx.spawn(async move |document, cx| { let change = cx .background_spawn(async move { toggle(&full, selection, format) }) .await; document .update(cx, |document, cx| { - if document.snapshot(cx) != taken { - return log::warn!("markdown document: format {format:?} lapsed"); - } - match change { - // Apply logs its own refusal. - Ok(change) => document.apply(format, change, &taken.0, taken.1, cx).ok(), - Err(why) => { - log::info!("markdown document: format {format:?} refused: {why:?}"); - None - } + document.formatting = false; + let done = if document.mode == MarkdownMode::Preview { + Err(FormatRefusal::Preview) + } else if document.snapshot(cx) != taken { + Err(FormatRefusal::Changed) + } else { + change.and_then(|change| { + document.apply(format, change, &taken.0, taken.1, cx) + }) }; + if let Err(why) = done { + log::info!("markdown document: format {format:?} refused: {why:?}"); + cx.emit(DocumentEvent::FormatRefused(why)); + } }) .ok(); - })); + }) + .detach(); Ok(()) } diff --git a/src/documents/document/format/clear.rs b/src/documents/document/format/clear.rs index 60ee3f4..73510ce 100644 --- a/src/documents/document/format/clear.rs +++ b/src/documents/document/format/clear.rs @@ -4,7 +4,7 @@ use std::ops::Range; use markdown::mdast::Node; -use super::{Change, FormatRefusal, after, block, cut, marks, room}; +use super::{Change, FormatRefusal, after, block, cut, holder, marks, room}; use crate::documents::{markdown::span, tree::inside}; /// The backslashes of escapes in a text node's span. @@ -25,10 +25,12 @@ fn escapes(text: &str, whole: Range) -> Vec> { } /// A node's inline marks, as Typora's `.md-meta`. -fn meta(node: &Node, text: &str) -> Vec> { +fn meta(node: &Node, text: &str, autolinks: &[Range]) -> Vec> { let whole = span(node); match node { Node::Html(_) => vec![whole], + // Autolink text keeps its backslashes. + Node::Text(_) if holder(autolinks, &whole).is_some() => Vec::new(), Node::Text(_) => escapes(text, whole), Node::InlineMath(_) => { let inner = inside(text, whole.clone(), '$'); @@ -45,7 +47,7 @@ pub(super) fn clear( nodes: &[&Node], selection: Range, ) -> Result { - let blocks: Vec> = (nodes.iter()) + let mut blocks: Vec> = (nodes.iter()) .filter(|node| block(node)) .map(|node| room(node, text)) .filter(|range| range.start <= selection.end && selection.start <= range.end) @@ -53,25 +55,29 @@ pub(super) fn clear( if blocks.is_empty() { return Err(FormatRefusal::Outside); } - let held = |hole: &Range| { - blocks - .iter() - .any(|block| block.start <= hole.start && hole.end <= block.end) - }; - let mut holes: Vec> = (nodes.iter()) - .flat_map(|node| meta(node, text)) - .filter(|hole| !hole.is_empty() && held(hole)) - .collect(); - holes.sort_by_key(|hole| hole.start); - let edits = (blocks.iter()) - .filter_map(|block| { - let inside: Vec> = (holes.iter()) - .filter(|hole| block.start <= hole.start && hole.end <= block.end) - .cloned() - .collect(); - (!inside.is_empty()).then(|| (block.clone(), cut(text, block.clone(), &inside))) - }) + blocks.sort_by_key(|block| block.start); + let mut autolinks: Vec> = (nodes.iter()) + .filter(|node| matches!(node, Node::Link(_))) + .map(|node| span(node)) + .filter(|whole| !text[whole.clone()].starts_with('[')) .collect(); + autolinks.sort_by_key(|link| link.start); + let mut inside: Vec>> = vec![Vec::new(); blocks.len()]; + for hole in (nodes.iter()).flat_map(|node| meta(node, text, &autolinks)) { + if let Some(at) = holder(&blocks, &hole).filter(|_| !hole.is_empty()) { + inside[at].push(hole); + } + } + let mut holes = Vec::new(); + let mut edits = Vec::new(); + for (block, mut cuts) in blocks.into_iter().zip(inside) { + if cuts.is_empty() { + continue; + } + cuts.sort_by_key(|hole| hole.start); + edits.push((block.clone(), cut(text, block, &cuts))); + holes.extend(cuts); + } Ok(( edits, after(selection.start, &holes)..after(selection.end, &holes), diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index dc1ea36..ea100f9 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -41,7 +41,7 @@ use crate::{ }; /// Texts this long parse at once; longer ones elsewhere. -const AT_ONCE: usize = 128 << 10; +const AT_ONCE: usize = 16 << 10; /// Longer texts are never read: source alone. pub const LARGEST_READ: usize = 2 << 20; @@ -58,6 +58,8 @@ pub enum DocumentEvent { Parsed, /// A secondary press moved the caret onto this. Pointed(DocumentTarget), + /// A long format, accepted, did not happen. + FormatRefused(FormatRefusal), } /// A top line asked, numbered so late callbacks know theirs. @@ -87,7 +89,7 @@ pub struct MarkdownDocument { /// Text changes counted; a read of an older one lapses. edits: u64, _parsing: Option>, - _formatting: Option>, + formatting: bool, list: ListState, open: Option, /// A top line asked for, until the view holds it. @@ -159,7 +161,7 @@ impl MarkdownDocument { parsed: Arc::default(), edits: 0, _parsing: None, - _formatting: None, + formatting: false, list: ListState::new(1, ListAlignment::Top, overdraw), open: None, top: None, diff --git a/src/documents/document/tests/format.rs b/src/documents/document/tests/format.rs index 14f987c..2af5aee 100644 --- a/src/documents/document/tests/format.rs +++ b/src/documents/document/tests/format.rs @@ -1,11 +1,19 @@ //! Inline formats toggle as Typora's (R4.6). +use std::{cell::RefCell, rc::Rc, time::Instant}; + use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext}; +use markdown::mdast::Node; use super::{open, opened, press, text}; use crate::documents::{ - FormatRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, MarkdownMode, - document::{AT_ONCE, format::toggle, visual}, + DocumentEvent, FormatRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, MarkdownMode, + document::{ + AT_ONCE, + format::{toggle, toggle_tree}, + visual, + }, + markdown::file_tree, }; /// `marked` toggled: `‸` is the caret, `⟨…⟩` the selection. @@ -238,6 +246,21 @@ fn a_long_text_formats_once_read_apart(cx: &mut TestAppContext) { assert!(text(&document, cx).starts_with("**x")); } +#[gpui::test] +fn many_short_paragraphs_format_apart(cx: &mut TestAppContext) { + let source = "a\n\n".repeat(AT_ONCE / 3 + 1); + let (document, cx) = open(&source, MarkdownMode::Source, cx); + document.update(cx, |document, cx| { + document + .editor + .update(cx, |editor, cx| editor.select(std::iter::once(0..1), cx)); + document.format(InlineFormat::Strong, cx).expect("taken"); + }); + assert_eq!(text(&document, cx), source, "not on the UI thread"); + cx.run_until_parked(); + assert!(text(&document, cx).starts_with("**a**\n\na")); +} + #[gpui::test] fn a_long_format_lapses_when_the_text_moves(cx: &mut TestAppContext) { let (document, cx) = long(cx, AT_ONCE + 1); @@ -254,6 +277,130 @@ fn a_long_format_lapses_when_the_text_moves(cx: &mut TestAppContext) { ); } +/// The refusals a document sends by event. +fn refusals( + document: &Entity, + cx: &mut VisualTestContext, +) -> Rc>> { + let heard = Rc::new(RefCell::new(Vec::new())); + let kept = heard.clone(); + cx.update(|_, cx| { + cx.subscribe(document, move |_, event: &DocumentEvent, _| { + if let DocumentEvent::FormatRefused(why) = event { + kept.borrow_mut().push(*why); + } + }) + .detach() + }); + heard +} + +#[gpui::test] +fn a_long_lapse_comes_by_event(cx: &mut TestAppContext) { + let (document, cx) = long(cx, AT_ONCE + 1); + let heard = refusals(&document, cx); + document.update(cx, |document, cx| { + document.format(InlineFormat::Strong, cx).expect("taken"); + document + .editor + .update(cx, |editor, cx| editor.edit([(0..0, "y".to_string())], cx)); + }); + cx.run_until_parked(); + assert_eq!(*heard.borrow(), [FormatRefusal::Changed]); +} + +#[test] +fn an_unwrap_refuses_an_end_in_nested_marks() { + use InlineFormat::*; + let across = Err(FormatRefusal::Across); + assert_eq!(toggled("**[a](ht‸tps://x)**", Strong), across); + assert_eq!(toggled("**~~a~‸~**", Strong), across); + assert_eq!(toggled("[a](ht‸tps://x)", Underline), across); + assert_eq!(toggled("**a *b⟨* c⟩**", Strong), across); +} + +#[test] +fn clear_keeps_an_autolink_backslash() { + assert_eq!( + ok(r"a‸ ", InlineFormat::Clear), + r"a‸ https://x/\*" + ); +} + +/// The walk past the parse, on `n` then `4n` repeats. +fn linear(unit: &str, run: impl Fn(&str, &Node)) { + let time = |n: usize| { + let text = unit.repeat(n); + let root = file_tree(&text); + let start = Instant::now(); + run(&text, &root); + start.elapsed() + }; + let (small, large) = (time(4000), time(16000)); + assert!(large < small * 8, "{small:?} then {large:?}"); +} + +#[test] +fn many_paragraphs_format_in_linear_time() { + linear("a\n\n", |text, root| { + toggle_tree(text, root, 0..1, InlineFormat::Strong).expect("toggled"); + }); + linear("**a**\n\n", |text, root| { + toggle_tree(text, root, 0..text.len(), InlineFormat::Clear).expect("cleared"); + }); +} + +#[gpui::test] +fn a_second_long_format_refuses_while_one_runs(cx: &mut TestAppContext) { + let (document, cx) = long(cx, AT_ONCE + 1); + let second = document.update(cx, |document, cx| { + document.format(InlineFormat::Strong, cx).expect("taken"); + document.format(InlineFormat::Emphasis, cx) + }); + assert_eq!(second, Err(FormatRefusal::Busy)); + cx.run_until_parked(); + document.update(cx, |document, cx| { + document.format(InlineFormat::Emphasis, cx).expect("taken") + }); + cx.run_until_parked(); + assert!(text(&document, cx).starts_with("***x")); + document.update(cx, |document, cx| { + document.editor.update(cx, |editor, cx| editor.undo(cx)); + }); + assert!(text(&document, cx).starts_with("**x")); + assert!(!text(&document, cx).starts_with("***")); +} + +#[gpui::test] +fn a_long_refusal_comes_by_event(cx: &mut TestAppContext) { + let source = format!("`code` {}", "x".repeat(AT_ONCE)); + let (document, cx) = open(&source, MarkdownMode::Source, cx); + let heard = refusals(&document, cx); + let taken = document.update(cx, |document, cx| { + document + .editor + .update(cx, |editor, cx| editor.select(std::iter::once(2..2), cx)); + document.format(InlineFormat::Strong, cx) + }); + assert_eq!(taken, Ok(())); + cx.run_until_parked(); + assert_eq!(*heard.borrow(), [FormatRefusal::Code]); + assert_eq!(text(&document, cx), source); +} + +#[gpui::test] +fn a_long_format_drops_once_preview_shows(cx: &mut TestAppContext) { + let (document, cx) = long(cx, AT_ONCE + 1); + let heard = refusals(&document, cx); + document.update_in(cx, |document, window, cx| { + document.format(InlineFormat::Strong, cx).expect("taken"); + document.set_mode(MarkdownMode::Preview, window, cx); + }); + cx.run_until_parked(); + assert!(text(&document, cx).starts_with("xx")); + assert_eq!(*heard.borrow(), [FormatRefusal::Preview]); +} + #[gpui::test] fn a_text_past_the_largest_read_refuses(cx: &mut TestAppContext) { let (document, cx) = long(cx, LARGEST_READ + 1);