T21a: Documents, editing

- markdown read with pulldown-cmark: marks for live styling, blocks, links, words
- forms::Highlight gains weight, slant and strike; measure and paint share runs
- Inter Italic embedded, so emphasis reads slanted
- forms::mention's popup and keys become Suggestions, shared with the editors
- RichTextEditor with FixedFormatToolbar, the floating toolbar, LinkEditor,
  @ and : suggestions, WordCount and ReadingTime
- MarkdownEditor: visual (a pressed block opens as source), source, split
- BlockEditor: prose, lists, to-dos, quotes, callouts, toggles, code, math,
  diagrams the app draws, dividers, tables that grow and merge, pictures to
  resize and align, cards for video and pages, columns, synced text; slash menu,
  markdown shortcuts, handles that drag and open a menu, one undo for all
- TodoItem and Checklist
- icons: heading-1..3, workflow, merge
- gallery: the Documents page with scripted states
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 03:32:24 -04:00
1 parent 0ffa001a61
commit 6a9ce8a2c3
46 files changed
+5635 -193

No files matched your search

+279
View File
@@ -0,0 +1,279 @@
use std::ops::Range;
use gpui::{App, FontWeight};
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use unicode_segmentation::UnicodeSegmentation;
use crate::{forms::Highlight, theme::ActiveTheme};
/// The markdown this crate reads: GitHub's tables, strikethrough and task lists, footnotes, and math between dollars.
pub(crate) fn options() -> Options {
Options::ENABLE_TABLES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_MATH
}
/// How a stretch of markdown prints while it is edited.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mark {
Strong,
Emphasis,
Strike,
Code,
Link,
Heading(u8),
Quote,
/// The characters that write the marks: asterisks, backticks, brackets, hashes.
Syntax,
}
/// The run of `ch` that starts `text`, in bytes.
fn leading(text: &str, ch: char) -> usize {
text.chars()
.take_while(|found| *found == ch)
.map(char::len_utf8)
.sum()
}
/// The marks over `text`'s bytes: styles, code, links, headings and quotes, and the syntax that writes each.
pub fn marks(text: &str) -> Vec<(Range<usize>, Mark)> {
let mut out = Vec::new();
let mut links: Vec<(Range<usize>, Option<Range<usize>>)> = Vec::new();
for (event, range) in Parser::new_ext(text, options()).into_offset_iter() {
let span = &text[range.clone()];
match event {
Event::Start(Tag::Strong | Tag::Emphasis | Tag::Strikethrough) => {
let mark = match event {
Event::Start(Tag::Strong) => Mark::Strong,
Event::Start(Tag::Emphasis) => Mark::Emphasis,
_ => Mark::Strike,
};
let first = span.chars().next().expect("a styled span has its marker");
let edge = leading(span, first).min(span.len() / 2);
let edge = match mark {
Mark::Emphasis => edge.min(first.len_utf8()),
_ => edge,
};
out.push((range.clone(), mark));
out.push((range.start..range.start + edge, Mark::Syntax));
out.push((range.end - edge..range.end, Mark::Syntax));
}
Event::Code(_) => {
let ticks = leading(span, '`');
out.push((range.clone(), Mark::Code));
out.push((range.start..range.start + ticks, Mark::Syntax));
out.push((range.end - ticks..range.end, Mark::Syntax));
}
Event::Start(Tag::Link { .. }) => links.push((range, None)),
Event::Text(_) if !links.is_empty() => {
let (_, inner) = links.last_mut().expect("inside a link");
*inner = Some(match inner.take() {
Some(inner) => inner.start..range.end,
None => range,
});
}
Event::End(TagEnd::Link) => {
let (whole, inner) = links.pop().expect("a link ends after it starts");
out.push((whole.clone(), Mark::Link));
if let Some(inner) = inner {
out.push((whole.start..inner.start, Mark::Syntax));
out.push((inner.end..whole.end, Mark::Syntax));
}
}
Event::Start(Tag::Heading { level, .. }) => {
let hashes = leading(span, '#');
out.push((range.clone(), Mark::Heading(level as u8)));
if hashes > 0 {
let spaced = hashes + leading(&span[hashes..], ' ');
out.push((range.start..range.start + spaced, Mark::Syntax));
}
}
Event::Start(Tag::BlockQuote(_)) => {
out.push((range.clone(), Mark::Quote));
let mut at = range.start;
for line in span.split_inclusive('\n') {
let marker = line.len() - line.trim_start_matches(['>', ' ']).len();
out.push((at..at + marker, Mark::Syntax));
at += line.len();
}
}
_ => {}
}
}
out.retain(|(range, _)| !range.is_empty());
out
}
/// Markdown styled as it is typed: strong and headings heavier, emphasis slanted, strikes struck, code washed, links in their tone; the syntax that writes them recedes.
pub fn markdown_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)> {
let colors = &cx.theme().colors;
let plain = Highlight::new(colors.fg);
marks(text)
.into_iter()
.map(|(range, mark)| {
let range = match mark {
Mark::Strike => {
let span = &text[range.clone()];
let edge = span.len() - span.trim_start_matches('~').len();
range.start + edge..range.end - edge
}
_ => range,
};
let highlight = match mark {
Mark::Strong | Mark::Heading(_) => Highlight {
weight: Some(FontWeight::SEMIBOLD),
..plain
},
Mark::Emphasis => Highlight {
italic: true,
..plain
},
Mark::Strike => Highlight {
strike: true,
..Highlight::new(colors.fg_muted)
},
Mark::Code => Highlight {
background: Some(colors.sunken),
..plain
},
Mark::Link => Highlight::new(colors.link),
Mark::Quote => Highlight::new(colors.fg_muted),
Mark::Syntax => Highlight::new(colors.fg_subtle),
};
(range, highlight)
})
.collect()
}
/// A link written `[text](address)` around byte `at`: its range, its text and its address.
pub fn link_at(text: &str, at: usize) -> Option<(Range<usize>, String, String)> {
Parser::new_ext(text, options())
.into_offset_iter()
.find_map(|(event, range)| match event {
Event::Start(Tag::Link { dest_url, .. })
if range.start <= at && at <= range.end && text[range.clone()].starts_with('[') =>
{
let written = &text[range.clone()];
let label = &written[1..written.rfind("](")?];
Some((range, label.to_string(), dest_url.to_string()))
}
_ => None,
})
}
/// The document's top-level blocks, as ranges of its source.
pub fn block_ranges(text: &str) -> Vec<Range<usize>> {
let mut depth = 0usize;
let mut out = Vec::new();
for (event, range) in Parser::new_ext(text, options()).into_offset_iter() {
match event {
Event::Start(_) => {
if depth == 0 {
out.push(range);
}
depth += 1;
}
Event::End(_) => depth -= 1,
Event::Rule if depth == 0 => out.push(range),
_ => {}
}
}
out.into_iter()
.map(|range| {
let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len();
range.start..range.start + kept
})
.collect()
}
/// Words in `text` as a reader counts them; each ideograph counts as one.
pub fn words(text: &str) -> usize {
text.unicode_words().count()
}
/// Words an adult reads in a minute, on average.
const PACE: usize = 238;
/// Minutes to read `words`, at least one when there are any.
pub fn reading_minutes(words: usize) -> usize {
words.div_ceil(PACE)
}
#[cfg(test)]
mod tests {
use super::*;
fn found(text: &str) -> Vec<(&str, Mark)> {
marks(text)
.into_iter()
.map(|(range, mark)| (&text[range], mark))
.collect()
}
#[test]
fn styles_carry_their_markers_as_syntax() {
use Mark::*;
assert_eq!(
found("a **bold** and *it* ~~no~~"),
[
("**bold**", Strong),
("**", Syntax),
("**", Syntax),
("*it*", Emphasis),
("*", Syntax),
("*", Syntax),
("~~no~~", Strike),
("~~", Syntax),
("~~", Syntax),
]
);
}
#[test]
fn code_links_and_headings_mark_their_syntax() {
use Mark::*;
assert_eq!(found("`x`"), [("`x`", Code), ("`", Syntax), ("`", Syntax)]);
assert_eq!(
found("see [the docs](https://ely.dev)"),
[
("[the docs](https://ely.dev)", Link),
("[", Syntax),
("](https://ely.dev)", Syntax)
]
);
assert_eq!(found("## Lift"), [("## Lift", Heading(2)), ("## ", Syntax)]);
}
#[test]
fn a_link_gives_its_range_text_and_address() {
let text = "see [the docs](https://ely.dev) or <https://x.dev>";
assert_eq!(
link_at(text, 6),
Some((4..31, "the docs".to_string(), "https://ely.dev".to_string()))
);
assert_eq!(link_at(text, 1), None);
assert_eq!(link_at(text, 40), None, "an autolink has no text to edit");
}
#[test]
fn blocks_are_the_top_level_stretches() {
let text = "# Lift\n\nBlend **it**.\n\n- a\n- b\n\n---\n";
let blocks: Vec<&str> = block_ranges(text)
.into_iter()
.map(|range| &text[range])
.collect();
assert_eq!(blocks, ["# Lift", "Blend **it**.", "- a\n- b", "---"]);
}
#[test]
fn reading_takes_a_minute_per_pace() {
assert_eq!(words("Blend toward white, by lift."), 5);
assert_eq!(words("你好世界"), 4);
assert_eq!(
(reading_minutes(0), reading_minutes(1), reading_minutes(239)),
(0, 1, 2)
);
}
}