diff --git a/AGENTS.md b/AGENTS.md index 79379ed..c1085f7 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. 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 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; code, math, HTML and front matter refuse, as does a selection crossing a block, a link or another mark (`FormatRefusal`); 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 new file mode 100644 index 0000000..94ee746 --- /dev/null +++ b/src/documents/document/format.rs @@ -0,0 +1,492 @@ +//! Inline formats toggle at the selection, as Typora's (R4.6). + +use std::ops::Range; + +use gpui::Context; +use markdown::mdast::Node; + +use super::MarkdownDocument; +use crate::documents::{ + MarkdownMode, + markdown::{file_tree, span, walk}, + modes::joint, + 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, +} + +/// The edits and the selection after them. +pub(super) type Change = (Vec<(Range, String)>, Range); + +/// 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 { + 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, + } +} + +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; 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]) +} + +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, + } +} + +/// `text[range]` without `holes`, sorted and inside it. +fn cut(text: &str, range: Range, holes: &[Range]) -> 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 { + 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, + format: InlineFormat, +) -> Result<(), FormatRefusal> { + let blocks: Vec> = nodes + .iter() + .filter(|node| block(node)) + .map(|node| span(node)) + .collect(); + 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, + }) + }; + 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 { + 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(_) if (inline && held) || (!inline && touches(true)) => FormatRefusal::Html, + _ => continue, + }; + return Err(why); + } + Ok(()) +} + +/// The `` and `` around `s..e` in `within`. +fn underline( + nodes: &[&Node], + within: &Range, + s: usize, + e: usize, +) -> Option<[Range; 2]> { + let tag = |node: &&&Node, name: &str| match node { + Node::Html(html) => html.value.trim().eq_ignore_ascii_case(name), + _ => false, + }; + let tags: Vec<(&Node, Range)> = nodes + .iter() + .filter(|node| matches!(node, Node::Html(_)) && within.contains(&span(node).start)) + .map(|node| (*node, span(node))) + .collect(); + let open = tags.iter().rfind(|(_, at)| at.end <= s)?; + let close = tags.iter().find(|(_, at)| at.start >= e)?; + let between = |from: usize, to: usize, name: &str| { + tags.iter() + .any(|(node, at)| at.start >= from && at.end <= to && tag(&node, name)) + }; + let paired = tag(&&open.0, "") && tag(&&close.0, ""); + (paired && !between(open.1.end, s, "") && !between(e, close.1.start, "")) + .then(|| [open.1.clone(), close.1.clone()]) +} + +/// `format` toggled at `selection` in `text`, or why not. +pub(super) fn toggle( + text: &str, + selection: Range, + format: InlineFormat, +) -> Result { + let root = file_tree(text); + let mut nodes = Vec::new(); + walk(&root, &mut nodes); + verbatim(&nodes, &selection, format)?; + if format == InlineFormat::Clear { + return 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 = |whole: &Range| match s == e { + true => whole.start < s && s < whole.end, + false => whole.start <= s && e <= whole.end, + }; + let unwrap = |range: Range, holes: [Range; 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(&span(node))) + .min_by_key(|node| span(node).len()); + if let Some(node) = found { + return Ok(unwrap( + span(node), + marks(node, text).expect("a styled node has marks"), + )); + } + let within = (nodes.iter()) + .filter(|node| block(node)) + .map(|node| content(node)) + .filter(|range| range.start <= s && e <= range.end) + .min_by_key(|range| range.len()); + let Some(within) = within else { + let line = text[..s] + .rsplit('\n') + .next() + .unwrap_or_default() + .to_string() + + text[s..].split('\n').next().unwrap_or_default(); + return match s == e && 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) + { + 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 => ("", ""), + 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 `s..e` cutting a node or landing in its marks. +fn crossed( + text: &str, + nodes: &[&Node], + within: &Range, + s: usize, + e: usize, + format: InlineFormat, +) -> Result<(), FormatRefusal> { + let merges = matches!( + format, + InlineFormat::Strong | InlineFormat::Emphasis | InlineFormat::Strike + ); + for node in nodes { + let whole = span(node); + let merged = merges && styled(format, node) && s <= whole.start && whole.end <= e; + if matches!(node, Node::Text(_)) + || merged + || 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 holds = whole.start <= s && e <= whole.end; + let cuts = + whole.start < e && s < whole.end && !holds && !(s <= whole.start && whole.end <= e); + let in_mark = |at: usize| holes.iter().any(|hole| hole.start < at && at < hole.end); + if cuts || in_mark(s) || in_mark(e) { + return Err(FormatRefusal::Across); + } + } + Ok(()) +} + +/// `s..e` in `format`, same marks inside taken in. +fn wrap( + text: &str, + nodes: &[&Node], + within: &Range, + mut s: usize, + mut e: usize, + format: InlineFormat, +) -> Result { + if matches!( + format, + InlineFormat::Strong | InlineFormat::Emphasis | InlineFormat::Strike + ) { + 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> = (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)) +} + +/// Every inline mark in the blocks `selection` touches. +fn clear(text: &str, nodes: &[&Node], selection: Range) -> Result { + let blocks: Vec> = (nodes.iter()) + .filter(|node| block(node)) + .map(|node| content(node)) + .filter(|range| range.start <= selection.end && selection.start <= range.end) + .collect(); + 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| match node { + Node::Html(_) => vec![span(node)], + _ => marks(node, text).map(Vec::from).unwrap_or_default(), + }) + .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))) + }) + .collect(); + Ok(( + edits, + after(selection.start, &holes)..after(selection.end, &holes), + )) +} + +impl MarkdownDocument { + /// `format` at the selection, Typora's way, or why not. + pub fn format( + &mut self, + format: InlineFormat, + cx: &mut Context, + ) -> Result<(), FormatRefusal> { + if self.mode == MarkdownMode::Preview { + return Err(FormatRefusal::Preview); + } + let text = self.editor.read(cx).text(); + let (full, offset, selection) = match &self.open { + Some(open) => { + 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) + } + None => (text.to_string(), 0, self.editor.read(cx).primary().range()), + }; + let (edits, selected) = toggle(&full, selection, format).inspect_err(|why| { + log::info!("markdown document: format {format:?} refused: {why:?}"); + })?; + 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(()) + } +} diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index 5ae89c7..93c6ba2 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -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}; diff --git a/src/documents/document/tests/format.rs b/src/documents/document/tests/format.rs new file mode 100644 index 0000000..9019567 --- /dev/null +++ b/src/documents/document/tests/format.rs @@ -0,0 +1,180 @@ +//! Inline formats toggle as Typora's (R4.6). + +use gpui::{Modifiers, TestAppContext}; + +use super::{open, opened, press, text}; +use crate::documents::{ + FormatRefusal, InlineFormat, MarkdownMode, + document::{format::toggle, visual}, +}; + +/// `marked` toggled: `‸` is the caret, `⟨…⟩` the selection. +fn toggled(marked: &str, format: InlineFormat) -> Result { + 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), "⟨a⟩"); + 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("a‸b", 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) d ![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("
\nx‸\n
", 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"); + }); + 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"); + }); + 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"); +} diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index a0fc09f..2c753d0 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -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; diff --git a/src/documents/mod.rs b/src/documents/mod.rs index a0046cb..f6cb424 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -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}; diff --git a/src/documents/tree/mod.rs b/src/documents/tree/mod.rs index 4286ba6..ddfb4d3 100644 --- a/src/documents/tree/mod.rs +++ b/src/documents/tree/mod.rs @@ -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. diff --git a/src/documents/tree/sources.rs b/src/documents/tree/sources.rs index 4df17b5..462bd02 100644 --- a/src/documents/tree/sources.rs +++ b/src/documents/tree/sources.rs @@ -38,7 +38,7 @@ pub(super) fn written(node: &Node, source: &str) -> Option<(Range, Escape } /// A code span's text, one end space cut each side. -fn inside(source: &str, whole: Range, mark: char) -> Range { +pub(crate) fn inside(source: &str, whole: Range, mark: char) -> Range { 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();