Compare commits

...
4 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Opus 5.5 1d0354592b documents: format always reads apart; read cutoff back to 128 KiB
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 23s
CI / crate (push) Failing after 12m9s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
The format command never parses on the UI thread; refusals past the
size checks come by event. The document's at-once read returns to
128 KiB, so a visual opening between the cutoffs starts at the top.
Timing test takes web_time::Instant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 13:14:49 -04:00
ZacharyZhang-NYandClaude Opus 5.5 0289fdaa19 documents: format checks nested marks, reports late refusals, reads small texts at once
Unwrapping a style now runs the crossing check on the found node, so an
end in a nested link address or strike mark refuses. Clear keeps an
autolink's backslashes. Block lookups use a sorted search, so the walk
grows linearly. A long format refuses a second one as Busy and reports
refusals, a changed text and a shown preview through FormatRefused.
markdown-rs grows with the square of its nodes, so texts read at once
only up to 16 KiB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 12:47:18 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d238e3b4f0 documents: format refuses ends in marks, clears more, reads long texts apart
Unwrap and merge check the original ends against marks and link
addresses. Blocks take text up to their end spaces; a cell between its
pipes. Clear also takes inline math's dollars, footnote brackets and
escape backslashes. Underline finds its <u> pair past other tags. Blank
lines split on CR too; a selection across blocks says Across. Past
128 KiB the toggle runs off the UI thread and lands only if text and
selection are unchanged; past the largest read it refuses (TooLong).
Clear and the document command move into format/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 12:16:09 -04:00
ZacharyZhang-NYandClaude Opus 5.5 68d33b4c54 documents: inline formats toggle as Typora's
MarkdownDocument::format toggles bold, italic, code, strike, underline,
link and image at the selection, or clears every inline mark from the
blocks it touches, following Typora's toggleStyle: off inside the style,
wrap a selection, take the caret's word or write an empty pair. Code,
math, HTML and front matter refuse, as does a selection crossing a
block, a link or another mark. An open block keeps its field open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 11:51:24 -04:00
11 changed files with 1116 additions and 5 deletions

No files matched your search

+1 -1
View File
@@ -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 `<code>` 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 `</br>` breaks as `<br>` 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 identitLine truncated
- 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 `<code>` 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 `</br>` breaks as `<br>` 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 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; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). 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 listLine truncated
- 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.
+444
View File
@@ -0,0 +1,444 @@
//! Inline formats toggle at the selection, as Typora's (R4.6).
mod apply;
mod clear;
use std::ops::Range;
use markdown::mdast::Node;
use crate::documents::{
markdown::{file_tree, span, walk},
tree::inside,
};
/// An inline format of Typora's Format menu.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InlineFormat {
Strong,
Emphasis,
Code,
Strike,
Underline,
Link,
Image,
/// Every inline mark in the touched blocks.
Clear,
}
/// Why a format did not happen.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FormatRefusal {
/// Preview takes no edits.
Preview,
Code,
Math,
Html,
FrontMatter,
/// No paragraph, heading or cell at the caret.
Outside,
/// The selection crosses a block or another mark.
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.
pub(super) type Change = (Vec<(Range<usize>, String)>, Range<usize>);
/// Typora's word stops: punctuation and space.
fn stop(ch: char) -> bool {
ch.is_whitespace() || "()[]^&_-+*#@!~`\\/{}'\":;<>,.?%".contains(ch)
}
/// The text a block holds: its children, past heading marks.
fn content(node: &Node) -> Range<usize> {
let whole = span(node);
match node.children().map(|kids| (kids.first(), kids.last())) {
Some((Some(first), Some(last))) => span(first).start..span(last).end,
_ => whole.end..whole.end,
}
}
/// Room for text: inside a cell's pipes, trailing spaces too.
fn room(node: &Node, text: &str) -> Range<usize> {
let inner = match node {
Node::TableCell(_) => {
let whole = span(node);
let cell = &text[whole.clone()];
whole.start + usize::from(cell.starts_with('|'))
..whole.end - usize::from(cell.len() > 1 && cell.ends_with('|'))
}
_ => content(node),
};
let rest = &text[inner.end..];
let spaces = rest.len() - rest.trim_start_matches([' ', '\t']).len();
inner.start..inner.end + spaces
}
/// Which of `sorted`, disjoint spans, holds `inner`.
pub(super) fn holder(sorted: &[Range<usize>], inner: &Range<usize>) -> Option<usize> {
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,
Node::Paragraph(_) | Node::Heading(_) | Node::TableCell(_)
)
}
/// A styled node's two marks, kept text between.
fn marks(node: &Node, text: &str) -> Option<[Range<usize>; 2]> {
let whole = span(node);
let inner = match node {
Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_) => content(node),
Node::Link(_) | Node::LinkReference(_) => content(node),
Node::InlineCode(_) => inside(text, whole.clone(), '`'),
Node::Image(_) | Node::ImageReference(_) => {
// Typora keeps the alt up to its first `]`.
let end = whole.start + text[whole.clone()].find(']')?;
whole.start + 2..end
}
_ => return None,
};
Some([whole.start..inner.start, inner.end..whole.end])
}
/// Whether `at` lies strictly inside one of `node`'s marks.
fn in_marks(node: &Node, text: &str, at: usize) -> bool {
marks(node, text).is_some_and(|holes| holes.iter().any(|hole| hole.start < at && at < hole.end))
}
fn styled(format: InlineFormat, node: &Node) -> bool {
match format {
InlineFormat::Strong => matches!(node, Node::Strong(_)),
InlineFormat::Emphasis => matches!(node, Node::Emphasis(_)),
InlineFormat::Code => matches!(node, Node::InlineCode(_)),
InlineFormat::Strike => matches!(node, Node::Delete(_)),
InlineFormat::Link => matches!(node, Node::Link(_) | Node::LinkReference(_)),
InlineFormat::Image => matches!(node, Node::Image(_) | Node::ImageReference(_)),
InlineFormat::Underline | InlineFormat::Clear => false,
}
}
fn merges(format: InlineFormat) -> bool {
matches!(
format,
InlineFormat::Strong | InlineFormat::Emphasis | InlineFormat::Strike
)
}
/// `text[range]` without `holes`, sorted and inside it.
fn cut(text: &str, range: Range<usize>, holes: &[Range<usize>]) -> String {
let mut out = String::new();
let mut at = range.start;
for hole in holes {
out.push_str(&text[at..hole.start]);
at = hole.end;
}
out.push_str(&text[at..range.end]);
out
}
/// Byte `at` once `holes` are cut; inside one, its start.
fn after(at: usize, holes: &[Range<usize>]) -> usize {
let gone: usize = holes
.iter()
.map(|hole| at.clamp(hole.start, hole.end) - hole.start)
.sum();
at - gone
}
/// Refuses an end inside code, math, HTML or front matter.
fn verbatim(
nodes: &[&Node],
selection: &Range<usize>,
format: InlineFormat,
) -> Result<(), FormatRefusal> {
let mut blocks: Vec<Range<usize>> = 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| {
[selection.start, selection.end]
.iter()
.any(|&at| match edge {
true => whole.start <= at && at <= whole.end,
false => whole.start < at && at < whole.end,
})
};
// Clear takes a block's inline code and tags too.
let held = format != InlineFormat::Clear && touches(false);
let why = match node {
Node::Code(_) if touches(true) => FormatRefusal::Code,
Node::Math(_) if touches(true) => FormatRefusal::Math,
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(_) => match holder(&blocks, &whole).is_some() {
true if held => FormatRefusal::Html,
false if touches(true) => FormatRefusal::Html,
_ => continue,
},
_ => continue,
};
return Err(why);
}
Ok(())
}
/// The nearest `<u>` and `</u>` pair around `s..e` in `within`.
fn underline(
nodes: &[&Node],
within: &Range<usize>,
s: usize,
e: usize,
) -> Option<[Range<usize>; 2]> {
let mut tags: Vec<(&str, Range<usize>)> = (nodes.iter())
.filter_map(|node| match node {
Node::Html(html) => Some((html.value.trim(), span(node))),
_ => None,
})
.filter(|(_, at)| within.start <= at.start && at.end <= within.end)
.collect();
tags.sort_by_key(|(_, at)| at.start);
let mut opened = Vec::new();
for (tag, at) in tags {
if tag.eq_ignore_ascii_case("<u>") {
opened.push(at);
} else if tag.eq_ignore_ascii_case("</u>")
&& let Some(open) = opened.pop()
&& open.end <= s
&& e <= at.start
{
// Inner pairs close first.
return Some([open, at]);
}
}
None
}
/// `format` toggled at `selection` in `text`, or why not.
pub(super) fn toggle(
text: &str,
selection: Range<usize>,
format: InlineFormat,
) -> Result<Change, FormatRefusal> {
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<usize>,
format: InlineFormat,
) -> Result<Change, FormatRefusal> {
let mut nodes = Vec::new();
walk(root, &mut nodes);
verbatim(&nodes, &selection, format)?;
if format == InlineFormat::Clear {
return clear::clear(text, &nodes, selection);
}
let picked = &text[selection.clone()];
let s = selection.start + (picked.len() - picked.trim_start().len());
let e = (selection.end - (picked.len() - picked.trim_end().len())).max(s);
let holds = |node: &Node| {
let whole = span(node);
let inside = match s == e {
true => whole.start < s && s < whole.end,
false => whole.start <= s && e <= whole.end,
};
inside && !in_marks(node, text, s) && !in_marks(node, text, e)
};
let unwrap = |range: Range<usize>, holes: [Range<usize>; 2]| -> Change {
let written = cut(text, range.clone(), &holes);
(vec![(range, written)], after(s, &holes)..after(e, &holes))
};
let found = (nodes.iter())
.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"),
));
}
let rooms: Vec<Range<usize>> = (nodes.iter())
.filter(|node| block(node))
.map(|node| room(node, text))
.collect();
let within = (rooms.iter())
.filter(|range| range.start <= s && e <= range.end)
.min_by_key(|range| range.len())
.cloned();
let Some(within) = within else {
if s != e {
let touched = (rooms.iter()).any(|range| range.start <= e && s <= range.end);
return Err(match touched {
true => FormatRefusal::Across,
false => FormatRefusal::Outside,
});
}
let line = text[..s]
.rsplit(['\r', '\n'])
.next()
.unwrap_or_default()
.to_string()
+ text[s..].split(['\r', '\n']).next().unwrap_or_default();
return match line.trim().is_empty() {
true => Ok(pair(s, format)),
false => Err(FormatRefusal::Outside),
};
};
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 {
return wrap(text, &nodes, &within, s, e, format);
}
let prev = text[within.start..s].chars().next_back();
let next = text[s..within.end].chars().next();
if prev.is_none_or(stop) && next.is_none_or(stop) {
crossed(text, &nodes, &within, s, s, format)?;
return Ok(pair(s, format));
}
let word = (nodes.iter())
.filter(|node| matches!(node, Node::Text(_)))
.map(|node| span(node))
.find(|run| run.start <= s && s <= run.end)
.map(|run| {
let start = (text[run.start..s].char_indices().rev())
.find(|(_, ch)| stop(*ch))
.map_or(run.start, |(at, ch)| run.start + at + ch.len_utf8());
let end = s + text[s..run.end].find(stop).unwrap_or(run.end - s);
start..end
})
.filter(|word| !word.is_empty());
match word {
Some(word) => wrap(text, &nodes, &within, word.start, word.end, format),
None => {
crossed(text, &nodes, &within, s, s, format)?;
Ok(pair(s, format))
}
}
}
/// The marks `format` writes around `inner`.
fn written(format: InlineFormat, inner: &str) -> (String, String) {
let (open, close) = match format {
InlineFormat::Strong => ("**", "**"),
InlineFormat::Emphasis => ("*", "*"),
InlineFormat::Strike => ("~~", "~~"),
InlineFormat::Underline => ("<u>", "</u>"),
InlineFormat::Link => ("[", "]()"),
InlineFormat::Image => ("![", "]()"),
InlineFormat::Code => {
let longest = inner.split(|ch| ch != '`').map(str::len).max().unwrap_or(0);
let fence = "`".repeat(longest + 1);
let pad = if inner.starts_with('`') || inner.ends_with('`') {
" "
} else {
""
};
return (format!("{fence}{pad}"), format!("{pad}{fence}"));
}
InlineFormat::Clear => unreachable!("clear writes no marks"),
};
(open.into(), close.into())
}
/// An empty pair at `at`, the caret inside it.
fn pair(at: usize, format: InlineFormat) -> Change {
let (open, close) = written(format, "");
let caret = at + open.len();
(vec![(at..at, format!("{open}{close}"))], caret..caret)
}
/// Refuses an end in marks, or a mark split off.
fn crossed(
text: &str,
nodes: &[&Node],
within: &Range<usize>,
s: usize,
e: usize,
format: InlineFormat,
) -> Result<(), FormatRefusal> {
for node in nodes {
let whole = span(node);
if matches!(node, Node::Text(_)) || whole.start < within.start || whole.end > within.end {
continue;
}
let holes = match node {
Node::InlineMath(_) | Node::Html(_) | Node::Break(_) | Node::FootnoteReference(_) => {
vec![whole.clone()]
}
_ => match marks(node, text) {
Some(marks) => marks.to_vec(),
None => continue,
},
};
let strictly = |at: usize| holes.iter().any(|hole| hole.start < at && at < hole.end);
let overlaps = holes.iter().any(|hole| s < hole.end && hole.start < e);
let holds_all = s <= whole.start && whole.end <= e;
// A same style cut is taken in whole.
let same = merges(format) && styled(format, node);
if strictly(s) || strictly(e) || (overlaps && !holds_all && !same) {
return Err(FormatRefusal::Across);
}
}
Ok(())
}
/// `s..e` in `format`, same marks inside taken in.
fn wrap(
text: &str,
nodes: &[&Node],
within: &Range<usize>,
mut s: usize,
mut e: usize,
format: InlineFormat,
) -> Result<Change, FormatRefusal> {
crossed(text, nodes, within, s, e, format)?;
if merges(format) {
for node in nodes.iter().filter(|node| styled(format, node)) {
let whole = span(node);
if whole.start < e
&& s < whole.end
&& within.start <= whole.start
&& whole.end <= within.end
{
(s, e) = (s.min(whole.start), e.max(whole.end));
}
}
crossed(text, nodes, within, s, e, format)?;
}
let mut holes: Vec<Range<usize>> = (nodes.iter())
.filter(|node| styled(format, node) && s <= span(node).start && span(node).end <= e)
.flat_map(|node| marks(node, text).expect("a styled node has marks"))
.filter(|hole| !hole.is_empty())
.collect();
holes.sort_by_key(|hole| hole.start);
let inner = cut(text, s..e, &holes);
let (open, close) = written(format, &inner);
let at = s + open.len();
let selection = match format {
InlineFormat::Link | InlineFormat::Image => at + inner.len() + 2..at + inner.len() + 2,
_ => at..at + inner.len(),
};
Ok((vec![(s..e, format!("{open}{inner}{close}"))], selection))
}
+138
View File
@@ -0,0 +1,138 @@
//! The document's format command.
use std::ops::Range;
use gpui::{AppContext as _, Context};
use super::{Change, FormatRefusal, InlineFormat, toggle};
use crate::documents::{
MarkdownMode,
document::{DocumentEvent, LARGEST_READ, MarkdownDocument},
modes::joint,
};
/// The text formatted, the open block's offset, the selection.
type Snapshot = (String, usize, Range<usize>);
impl MarkdownDocument {
/// `format` at the selection, read apart; refusals by event.
pub fn format(
&mut self,
format: InlineFormat,
cx: &mut Context<Self>,
) -> Result<(), FormatRefusal> {
if self.mode == MarkdownMode::Preview {
return Err(FormatRefusal::Preview);
}
let (full, offset, selection) = self.snapshot(cx);
if full.len() > LARGEST_READ {
log::info!(
"markdown document: format {format:?} refused: {} bytes",
full.len()
);
return Err(FormatRefusal::TooLong);
}
if self.formatting {
log::info!("markdown document: format {format:?} refused: one runs");
return Err(FormatRefusal::Busy);
}
log::info!(
"markdown document: format {format:?} reads {} bytes apart",
full.len()
);
let taken = (full.clone(), offset, selection.clone());
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| {
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(())
}
/// The text a format reads: the open block's in place.
fn snapshot(&self, cx: &Context<Self>) -> Snapshot {
let text = self.editor.read(cx).text();
let Some(open) = &self.open else {
return (text.to_string(), 0, self.editor.read(cx).primary().range());
};
let input = open.input.read(cx);
let (before, after) = match open.append {
true => (format!("{text}{}", joint(text)), ""),
false => (
text[..open.range.start].to_string(),
&text[open.range.end..],
),
};
let picked = input.selection();
let offset = before.len();
let full = format!("{before}{}{after}", input.text());
(full, offset, offset + picked.start..offset + picked.end)
}
/// Writes `change`, into the open block if one is.
fn apply(
&mut self,
format: InlineFormat,
(edits, selected): Change,
full: &str,
offset: usize,
cx: &mut Context<Self>,
) -> Result<(), FormatRefusal> {
log::info!("markdown document: format {format:?}");
let Some(open) = &self.open else {
self.editor.update(cx, |editor, cx| {
editor.edit(edits, cx);
editor.select(std::iter::once(selected), cx);
});
return Ok(());
};
let end = offset + open.input.read(cx).text().len();
if !edits
.iter()
.all(|(range, _)| offset <= range.start && range.end <= end)
{
log::warn!("markdown document: format {format:?} reaches past the open block");
return Err(FormatRefusal::Across);
}
let (Some(from), Some(to)) = (
edits.iter().map(|(range, _)| range.start).min(),
edits.iter().map(|(range, _)| range.end).max(),
) else {
return Ok(());
};
let mut written = String::new();
let mut at = from;
for (range, with) in &edits {
written.push_str(&full[at..range.start]);
written.push_str(with);
at = range.end;
}
written.push_str(&full[at..to]);
open.input.update(cx, |input, cx| {
input.select(from - offset..to - offset, cx);
input.insert(&written, cx);
input.select(selected.start - offset..selected.end - offset, cx);
});
Ok(())
}
}
+85
View File
@@ -0,0 +1,85 @@
//! Clear Format: every inline mark off the touched blocks.
use std::ops::Range;
use markdown::mdast::Node;
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.
fn escapes(text: &str, whole: Range<usize>) -> Vec<Range<usize>> {
let mut out = Vec::new();
let mut chars = text[whole.clone()].char_indices().peekable();
while let Some((at, ch)) = chars.next() {
if ch == '\\'
&& chars
.peek()
.is_some_and(|(_, next)| next.is_ascii_punctuation())
{
out.push(whole.start + at..whole.start + at + 1);
chars.next();
}
}
out
}
/// A node's inline marks, as Typora's `.md-meta`.
fn meta(node: &Node, text: &str, autolinks: &[Range<usize>]) -> Vec<Range<usize>> {
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(), '$');
vec![whole.start..inner.start, inner.end..whole.end]
}
Node::FootnoteReference(_) => vec![whole.start..whole.start + 2, whole.end - 1..whole.end],
_ => marks(node, text).map(Vec::from).unwrap_or_default(),
}
}
/// Every inline mark in the blocks `selection` touches.
pub(super) fn clear(
text: &str,
nodes: &[&Node],
selection: Range<usize>,
) -> Result<Change, FormatRefusal> {
let mut blocks: Vec<Range<usize>> = (nodes.iter())
.filter(|node| block(node))
.map(|node| room(node, text))
.filter(|range| range.start <= selection.end && selection.start <= range.end)
.collect();
if blocks.is_empty() {
return Err(FormatRefusal::Outside);
}
blocks.sort_by_key(|block| block.start);
let mut autolinks: Vec<Range<usize>> = (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<Range<usize>>> = 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),
))
}
+6
View File
@@ -1,6 +1,7 @@
mod attach;
mod edit;
mod finding;
mod format;
mod lists;
mod look;
mod parse;
@@ -22,6 +23,7 @@ use gpui::{
pub use crate::documents::tree::DocumentHeading;
pub use attach::Attachment;
pub use format::{FormatRefusal, InlineFormat};
pub use look::DocumentLook;
use parse::{Parsed, alike, parsed};
pub use point::{DocumentTarget, TargetBlock};
@@ -56,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.
@@ -85,6 +89,7 @@ pub struct MarkdownDocument {
/// Text changes counted; a read of an older one lapses.
edits: u64,
_parsing: Option<Task<()>>,
formatting: bool,
list: ListState,
open: Option<Open>,
/// A top line asked for, until the view holds it.
@@ -156,6 +161,7 @@ impl MarkdownDocument {
parsed: Arc::default(),
edits: 0,
_parsing: None,
formatting: false,
list: ListState::new(1, ListAlignment::Top, overdraw),
open: None,
top: None,
+429
View File
@@ -0,0 +1,429 @@
//! Inline formats toggle as Typora's (R4.6).
use std::{cell::RefCell, rc::Rc};
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext};
use markdown::mdast::Node;
use web_time::Instant;
use super::{open, opened, press, text};
use crate::documents::{
DocumentEvent, FormatRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, MarkdownMode,
document::{
format::{toggle, toggle_tree},
visual,
},
markdown::file_tree,
};
/// `marked` toggled: `‸` is the caret, `⟨…⟩` the selection.
fn toggled(marked: &str, format: InlineFormat) -> Result<String, FormatRefusal> {
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) = toggle(&text, selection, format)?;
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, format: InlineFormat) -> String {
toggled(marked, format).expect("toggled")
}
#[test]
fn a_selection_wraps_and_a_styled_one_unwraps() {
use InlineFormat::*;
assert_eq!(ok("a ⟨bold⟩ z", Strong), "a **⟨bold⟩** z");
assert_eq!(
ok("a ⟨word ⟩z", Strong),
"a **⟨word⟩** z",
"end space stays out"
);
assert_eq!(ok("a **⟨bold⟩** z", Strong), "a ⟨bold⟩ z");
assert_eq!(ok("⟨a⟩", Emphasis), "*⟨a⟩*");
assert_eq!(ok("⟨a⟩", Strike), "~~⟨a⟩~~");
assert_eq!(ok("⟨a⟩", Underline), "<u>⟨a⟩</u>");
assert_eq!(ok("x ⟨a`b⟩ y", Code), "x ``⟨a`b⟩`` y");
assert_eq!(ok("⟨site⟩", Link), "[site](‸)");
assert_eq!(ok("⟨cat⟩", Image), "![cat](‸)");
}
#[test]
fn a_caret_takes_its_word_or_an_empty_pair() {
use InlineFormat::*;
assert_eq!(ok("a wo‸rd z", Strong), "a **⟨word⟩** z");
assert_eq!(ok("a ‸ z", Strong), "a **‸** z");
assert_eq!(ok("a\n\n‸", Emphasis), "a\n\n*‸*", "a blank line");
assert_eq!(ok("a ‸ z", Link), "a [‸]() z");
assert_eq!(
ok("x wo‸rd", Strong),
"x **⟨word⟩**",
"a wide space stops it"
);
}
#[test]
fn a_caret_in_a_style_takes_it_off() {
use InlineFormat::*;
assert_eq!(ok("a **b‸old** z", Strong), "a b‸old z");
assert_eq!(ok("~~a‸~~", Strike), "a‸");
assert_eq!(ok("x `co‸de` y", Code), "x co‸de y");
assert_eq!(ok("<u>a‸b</u>", Underline), "a‸b");
assert_eq!(ok("[si‸te](https://x)", Link), "si‸te");
assert_eq!(ok("![c‸at](x.png)", Image), "c‸at");
}
#[test]
fn a_partly_bold_selection_takes_the_bold_in() {
assert_eq!(ok("a **b** ⟨c⟩", InlineFormat::Strong), "a **b** **⟨c⟩**");
assert_eq!(ok("a **⟨b** c⟩", InlineFormat::Strong), "a **⟨b c⟩**");
}
#[test]
fn clear_takes_every_mark_off_the_touched_blocks() {
let cleared = ok(
"**a** ‸`b` [c](u) <u>d</u> ![e](f.png)",
InlineFormat::Clear,
);
assert_eq!(cleared, "a ‸b c d e");
assert_eq!(ok("**a‸**\n\n*b*", InlineFormat::Clear), "a‸\n\n*b*");
assert_eq!(ok("⟨**a**\n\n*b*⟩", InlineFormat::Clear), "⟨a\n\nb⟩");
}
#[test]
fn code_math_html_and_front_matter_refuse() {
use FormatRefusal as Why;
use InlineFormat::*;
assert_eq!(toggled("```\nco‸de\n```", Strong), Err(Why::Code));
assert_eq!(toggled("x `co‸de` y", Strong), Err(Why::Code));
assert_eq!(toggled("$$\nx‸\n$$", Strong), Err(Why::Math));
assert_eq!(toggled("a $x‸$ b", Emphasis), Err(Why::Math));
assert_eq!(toggled("<div>\nx‸\n</div>", Strong), Err(Why::Html));
assert_eq!(
toggled("---\nti‸tle: a\n---\n\nbody", Strong),
Err(Why::FrontMatter)
);
assert_eq!(toggled("```\nco‸de\n```", Clear), Err(Why::Code));
}
#[test]
fn a_selection_across_a_link_or_into_its_address_refuses() {
use InlineFormat::*;
assert_eq!(toggled("[a ⟨b](u) c⟩", Strong), Err(FormatRefusal::Across));
assert_eq!(
toggled("[a](ht‸tp://x)", Strong),
Err(FormatRefusal::Across)
);
assert_eq!(toggled("a\n\n> ‸", Strong), Err(FormatRefusal::Outside));
}
#[gpui::test]
fn an_open_block_stays_open_and_undoes_as_one_step(cx: &mut TestAppContext) {
let (document, cx) = open("alpha beta\n", MarkdownMode::Visual, cx);
press(0, Modifiers::none(), cx);
document.update(cx, |document, cx| {
let open = document.open.as_ref().expect("opened");
open.input.update(cx, |input, cx| input.select(6..10, cx));
document
.format(InlineFormat::Strong, cx)
.expect("formatted");
});
cx.run_until_parked();
assert_eq!(
opened(&document, cx),
"alpha **beta**",
"still open to type in"
);
document.update(cx, |document, cx| {
let open = document.open.as_ref().expect("opened");
assert_eq!(
open.input.read(cx).selection(),
8..12,
"the word stays picked"
);
});
cx.dispatch_action(visual::Undo);
cx.run_until_parked();
assert_eq!(text(&document, cx), "alpha beta\n");
}
#[gpui::test]
fn the_source_takes_a_format_as_one_step(cx: &mut TestAppContext) {
let (document, cx) = open("alpha beta\n", MarkdownMode::Source, cx);
document.update(cx, |document, cx| {
document
.editor
.update(cx, |editor, cx| editor.select(std::iter::once(2..2), cx));
document.format(InlineFormat::Code, cx).expect("formatted");
});
cx.run_until_parked();
assert_eq!(text(&document, cx), "`alpha` beta\n");
document.update(cx, |document, cx| {
document.editor.update(cx, |editor, cx| editor.undo(cx));
});
assert_eq!(text(&document, cx), "alpha beta\n");
}
#[gpui::test]
fn preview_refuses_a_format(cx: &mut TestAppContext) {
let (document, cx) = open("alpha\n", MarkdownMode::Preview, cx);
let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx));
assert_eq!(refused, Err(FormatRefusal::Preview));
assert_eq!(text(&document, cx), "alpha\n");
}
#[test]
fn an_end_in_marks_or_an_address_refuses() {
use InlineFormat::*;
let across = Err(FormatRefusal::Across);
assert_eq!(toggled("a *‸*bold** z", Strong), across);
assert_eq!(toggled("[a](ht‸tps://example.com)", Link), across);
assert_eq!(toggled("a *⟨*b** c⟩", Strong), across);
assert_eq!(toggled("*a⟨b*⟩", Strong), across);
assert_eq!(toggled("⟨alpha\n\nbeta⟩", Strong), across);
}
#[test]
fn end_spaces_cells_and_cr_lines_take_a_pair() {
use InlineFormat::*;
assert_eq!(ok("alpha ‸", Strong), "alpha **‸**");
assert_eq!(ok("| h |\n| - |\n| ‸ |", Strong), "| h |\n| - |\n| **‸** |");
assert_eq!(ok("a\r‸\rb", Strong), "a\r**‸**\rb");
}
#[test]
fn clear_takes_math_footnote_and_escape_marks() {
use InlineFormat::Clear;
assert_eq!(ok("**a** $x‸+y$", Clear), "a x‸+y");
assert_eq!(ok("a‸ [^n]\n\n[^n]: note", Clear), "a‸ n\n\n[^n]: note");
assert_eq!(ok(r"a‸ \*b\*", Clear), "a‸ *b*");
}
#[test]
fn underline_comes_off_past_other_tags() {
use InlineFormat::Underline;
assert_eq!(ok("<u>a <br>b‸c</u>", Underline), "a <br>b‸c");
assert_eq!(ok("<u>a <em>b‸c</em> d</u>", Underline), "a <em>b‸c</em> d");
}
/// A source document of one word, caret at 1.
fn word(cx: &mut TestAppContext, len: usize) -> (Entity<MarkdownDocument>, &mut VisualTestContext) {
let (document, cx) = open(&"x".repeat(len), MarkdownMode::Source, cx);
document.update(cx, |document, cx| {
document
.editor
.update(cx, |editor, cx| editor.select(std::iter::once(1..1), cx));
});
(document, cx)
}
#[gpui::test]
fn a_format_reads_apart(cx: &mut TestAppContext) {
let (document, cx) = word(cx, 64);
document.update(cx, |document, cx| {
document.format(InlineFormat::Strong, cx).expect("taken")
});
assert!(
!text(&document, cx).starts_with("**"),
"not on the UI thread"
);
cx.run_until_parked();
assert!(text(&document, cx).starts_with("**x"));
}
#[gpui::test]
fn many_short_paragraphs_format_apart(cx: &mut TestAppContext) {
let source = "a\n\n".repeat(100);
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_format_lapses_when_the_text_moves(cx: &mut TestAppContext) {
let (document, cx) = word(cx, 64);
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!(
text(&document, cx).starts_with("yx"),
"the typing stays, unformatted"
);
}
/// The refusals a document sends by event.
fn refusals(
document: &Entity<MarkdownDocument>,
cx: &mut VisualTestContext,
) -> Rc<RefCell<Vec<FormatRefusal>>> {
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_lapse_comes_by_event(cx: &mut TestAppContext) {
let (document, cx) = word(cx, 64);
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("<u>[a](ht‸tps://x)</u>", Underline), across);
assert_eq!(toggled("**a *b⟨* c⟩**", Strong), across);
}
#[test]
fn clear_keeps_an_autolink_backslash() {
assert_eq!(
ok(r"a‸ <https://x/\*>", 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_format_refuses_while_one_runs(cx: &mut TestAppContext) {
let (document, cx) = word(cx, 64);
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_refusal_comes_by_event(cx: &mut TestAppContext) {
let source = format!("`code` {}", "x".repeat(64));
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_format_drops_once_preview_shows(cx: &mut TestAppContext) {
let (document, cx) = word(cx, 64);
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) = word(cx, LARGEST_READ + 1);
let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx));
assert_eq!(refused, Err(FormatRefusal::TooLong));
}
#[gpui::test]
fn a_visual_format_reads_apart(cx: &mut TestAppContext) {
let (document, cx) = open("word\n\nmore\n", MarkdownMode::Visual, cx);
document.update(cx, |document, cx| {
document
.editor
.update(cx, |editor, cx| editor.select(std::iter::once(1..1), cx));
document.format(InlineFormat::Strong, cx).expect("taken");
});
assert_eq!(
text(&document, cx),
"word\n\nmore\n",
"not on the UI thread"
);
cx.run_until_parked();
assert_eq!(text(&document, cx), "**word**\n\nmore\n");
}
+1
View File
@@ -366,6 +366,7 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) {
}
mod attach;
mod format;
mod history;
mod links;
mod look;
+8
View File
@@ -229,3 +229,11 @@ fn the_selection_reads_in_source_and_in_an_open_block(cx: &mut TestAppContext) {
"words"
);
}
#[gpui::test]
fn a_visual_opening_starts_at_the_top(cx: &mut TestAppContext) {
let source = format!("alpha beta\n\n{}", "x".repeat(20000));
let (document, cx) = open(&source, MarkdownMode::Visual, cx);
cx.run_until_parked();
assert_eq!(landed(&document, cx), 0);
}
+2 -2
View File
@@ -36,8 +36,8 @@ pub use counts::{ReadingTime, WordCount};
pub use diagram::{Diagram, diagram_svg};
pub(crate) use diagram::{flatten, options as svg_options};
pub use document::{
Attachment, DocumentEvent, DocumentHeading, DocumentLook, DocumentTarget, LARGEST_READ,
MarkdownDocument, TableEdit, TableRefusal, TargetBlock,
Attachment, DocumentEvent, DocumentHeading, DocumentLook, DocumentTarget, FormatRefusal,
InlineFormat, LARGEST_READ, MarkdownDocument, TableEdit, TableRefusal, TargetBlock,
};
pub use epub::{Chapter, EpubReader};
pub use format::{Format, format};
+1 -1
View File
@@ -14,7 +14,7 @@ mod stack;
pub use blocks::{AlertKind, DocumentHeading, alert, is_toc};
use html_block::html_block_text;
pub use sources::Escapes;
pub(crate) use sources::lines_at;
pub(crate) use sources::{inside, lines_at};
use stack::close;
/// How a stretch of inline text prints.
+1 -1
View File
@@ -38,7 +38,7 @@ pub(super) fn written(node: &Node, source: &str) -> Option<(Range<usize>, Escape
}
/// A code span's text, one end space cut each side.
fn inside(source: &str, whole: Range<usize>, mark: char) -> Range<usize> {
pub(crate) fn inside(source: &str, whole: Range<usize>, mark: char) -> Range<usize> {
let written = &source[whole.clone()];
let open = written.len() - written.trim_start_matches(mark).len();
let close = written.len() - written.trim_end_matches(mark).len();