MarkdownDocument: a Markdown file in one CodeEditor, shown visual, source, split or preview
Visual draws the top-level blocks in a gpui list; a pressed block opens as its source and its typing is the text at once, written back as one undo step. CodeEditor takes the owner's line colors, resets with a fresh history and keeps at most 64 MiB of undo text. MarkdownMode gains Preview. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9036c92442
commit
2b6f5513aa
19 files changed
+1267
-86
No files matched your search
@@ -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. `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. `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. Texts up to 128 KiB are read at once, longer ones on the background executor, and a new read keeps the list's blocks alike at both ends. `CodeEditor` keeps at most 64 MiB of undo text and drops the oldest steps past it. `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.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
use gpui::SharedString;
|
||||
|
||||
use super::tree::{Node, parse};
|
||||
|
||||
/// The anchor of the document's `ordinal`th heading.
|
||||
pub(crate) fn anchor(ordinal: usize) -> SharedString {
|
||||
format!("heading-{ordinal}").into()
|
||||
}
|
||||
|
||||
/// Headings in order for a contents list: anchor, text, depth.
|
||||
pub fn outline(markdown: &str) -> Vec<(SharedString, SharedString, usize)> {
|
||||
fn walk(nodes: &[Node], out: &mut Vec<(SharedString, SharedString, usize)>) {
|
||||
for node in nodes {
|
||||
match node {
|
||||
Node::Heading(level, inline) => {
|
||||
let ordinal = out.len();
|
||||
out.push((
|
||||
anchor(ordinal),
|
||||
inline.text.clone().into(),
|
||||
*level as usize - 1,
|
||||
));
|
||||
}
|
||||
Node::Quote(inner) | Node::Footnote { body: inner, .. } => walk(inner, out),
|
||||
Node::List { items, .. } => {
|
||||
for (_, body) in items {
|
||||
walk(body, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(&parse(markdown), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::outline;
|
||||
|
||||
#[test]
|
||||
fn the_outline_numbers_headings_in_order_with_their_depth() {
|
||||
let found =
|
||||
outline("# Lift\n\ntext\n\n## In light\n\n> ### Quoted\n\n- #### Listed\n\n## In dark");
|
||||
let shape: Vec<(&str, &str, usize)> = found
|
||||
.iter()
|
||||
.map(|(anchor, text, depth)| (anchor.as_ref(), text.as_ref(), *depth))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shape,
|
||||
[
|
||||
("heading-0", "Lift", 0),
|
||||
("heading-1", "In light", 1),
|
||||
("heading-2", "Quoted", 2),
|
||||
("heading-3", "Listed", 3),
|
||||
("heading-4", "In dark", 1),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
mod parse;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
mod visual;
|
||||
|
||||
pub(crate) use visual::bind_keys;
|
||||
|
||||
use std::{ops::Range, path::Path, rc::Rc, sync::Arc};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
|
||||
ListState, Subscription, Task, Window,
|
||||
};
|
||||
|
||||
pub use parse::DocumentHeading;
|
||||
use parse::{Parsed, alike, parsed};
|
||||
|
||||
use super::{MarkdownMode, markdown::markdown_highlights, modes::joint, render::OnLink};
|
||||
use crate::{
|
||||
editor::{CodeEditor, EditorEvent, LineNumbers},
|
||||
forms::TextInput,
|
||||
};
|
||||
|
||||
/// Texts this long parse at once; longer ones elsewhere.
|
||||
const AT_ONCE: usize = 128 << 10;
|
||||
|
||||
/// What the document tells its owner.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DocumentEvent {
|
||||
/// The text changed, typed in any mode or replaced.
|
||||
Changed,
|
||||
/// A new read of the text: blocks and headings.
|
||||
Parsed,
|
||||
}
|
||||
|
||||
/// The block open as its source, in its own field.
|
||||
struct Open {
|
||||
range: Range<usize>,
|
||||
input: Entity<TextInput>,
|
||||
/// Opened past the end, to add a block.
|
||||
append: bool,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
/// A Markdown file: one text, one history, three views.
|
||||
pub struct MarkdownDocument {
|
||||
editor: Entity<CodeEditor>,
|
||||
mode: MarkdownMode,
|
||||
base: Option<Arc<Path>>,
|
||||
parsed: Arc<Parsed>,
|
||||
/// Text changes counted; `read` is the one parsed.
|
||||
edits: u64,
|
||||
read: u64,
|
||||
_parsing: Option<Task<()>>,
|
||||
list: ListState,
|
||||
open: Option<Open>,
|
||||
/// Where the caret rests while no block is open.
|
||||
rest: usize,
|
||||
focus: FocusHandle,
|
||||
on_link: Option<OnLink>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl EventEmitter<DocumentEvent> for MarkdownDocument {}
|
||||
|
||||
impl Focusable for MarkdownDocument {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => self.editor.focus_handle(cx),
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => self.focus.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
pub fn new(
|
||||
text: impl Into<String>,
|
||||
mode: MarkdownMode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let editor = cx.new(|cx| {
|
||||
CodeEditor::new(text, window, cx)
|
||||
.language("Markdown")
|
||||
.line_numbers(LineNumbers::Hidden)
|
||||
.hide_indent_guides()
|
||||
.highlighter(markdown_highlights)
|
||||
});
|
||||
let subscriptions = vec![cx.subscribe(&editor, |document, _, event, cx| {
|
||||
if *event == EditorEvent::Changed {
|
||||
document.changed(cx);
|
||||
}
|
||||
})];
|
||||
let overdraw = window.rem_size() * 40.0;
|
||||
let mut document = Self {
|
||||
editor,
|
||||
mode,
|
||||
base: None,
|
||||
parsed: Arc::default(),
|
||||
edits: 0,
|
||||
read: u64::MAX,
|
||||
_parsing: None,
|
||||
list: ListState::new(1, ListAlignment::Top, overdraw),
|
||||
open: None,
|
||||
rest: 0,
|
||||
focus: cx.focus_handle(),
|
||||
on_link: None,
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
document.reparse(cx);
|
||||
document
|
||||
}
|
||||
|
||||
/// The folder relative pictures load from.
|
||||
pub fn set_base(&mut self, folder: impl Into<Arc<Path>>, cx: &mut Context<Self>) {
|
||||
self.base = Some(folder.into());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Takes link presses; without it the system opens them.
|
||||
pub fn on_link(&mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) {
|
||||
self.on_link = Some(Rc::new(handler));
|
||||
}
|
||||
|
||||
pub fn editor(&self) -> &Entity<CodeEditor> {
|
||||
&self.editor
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> MarkdownMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Shows the text as `mode`; an open block writes first.
|
||||
pub fn set_mode(&mut self, mode: MarkdownMode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.commit(cx);
|
||||
log::info!("markdown document: {:?} to {mode:?}", self.mode);
|
||||
self.mode = mode;
|
||||
let focus = self.focus_handle(cx);
|
||||
window.focus(&focus, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The text now, an open block's typing included.
|
||||
pub fn text(&self, cx: &App) -> String {
|
||||
let text = self.editor.read(cx).text();
|
||||
let Some(open) = &self.open else {
|
||||
return text.to_string();
|
||||
};
|
||||
let typed = open.input.read(cx).text();
|
||||
let written = match open.append {
|
||||
true if typed.is_empty() => String::new(),
|
||||
true => format!("{}{typed}", joint(text)),
|
||||
false => typed.to_string(),
|
||||
};
|
||||
[&text[..open.range.start], &written, &text[open.range.end..]].concat()
|
||||
}
|
||||
|
||||
/// Replaces the text in one step; open typing drops.
|
||||
pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
|
||||
self.drop_open(cx);
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.set_text(text, cx));
|
||||
}
|
||||
|
||||
/// Replaces the text, undo afresh; open typing drops.
|
||||
pub fn reset(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
|
||||
self.drop_open(cx);
|
||||
self.rest = 0;
|
||||
self.editor.update(cx, |editor, cx| editor.reset(text, cx));
|
||||
}
|
||||
|
||||
/// The headings as last read, with lines from zero.
|
||||
pub fn outline(&self, cx: &App) -> Vec<(DocumentHeading, usize)> {
|
||||
let editor = self.editor.read(cx);
|
||||
self.parsed
|
||||
.headings
|
||||
.iter()
|
||||
.filter(|heading| heading.at <= editor.text().len())
|
||||
.map(|heading| (heading.clone(), editor.buffer.line_of(heading.at)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the blocks shown read the text now.
|
||||
pub fn read_now(&self) -> bool {
|
||||
self.read == self.edits
|
||||
}
|
||||
|
||||
/// The caret's line and column, from zero.
|
||||
pub fn caret(&self, cx: &App) -> (usize, usize) {
|
||||
let editor = self.editor.read(cx);
|
||||
let at = match (&self.open, self.mode) {
|
||||
(_, MarkdownMode::Source | MarkdownMode::Split) => editor.primary().head,
|
||||
(Some(open), _) => {
|
||||
let input = open.input.read(cx);
|
||||
let line = editor
|
||||
.buffer
|
||||
.line_of(open.range.start.min(editor.text().len()));
|
||||
let typed = &input.text()[..input.cursor()];
|
||||
let column = match typed.rfind('\n') {
|
||||
Some(newline) => typed[newline + 1..].chars().count(),
|
||||
None => {
|
||||
let start = editor.buffer.line_range(line).start;
|
||||
editor.text()[start..open.range.start].chars().count()
|
||||
+ typed.chars().count()
|
||||
}
|
||||
};
|
||||
return (line + typed.matches('\n').count(), column);
|
||||
}
|
||||
(None, _) => self.rest,
|
||||
};
|
||||
editor.buffer.point(at.min(editor.text().len()))
|
||||
}
|
||||
|
||||
/// The first line in view.
|
||||
pub fn top_line(&self, cx: &App) -> usize {
|
||||
let editor = self.editor.read(cx);
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => editor.top_line().unwrap_or(0),
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => {
|
||||
let ix = self.list.logical_scroll_top().item_ix;
|
||||
self.parsed.blocks.get(ix).map_or(0, |block| {
|
||||
editor
|
||||
.buffer
|
||||
.line_of(block.range.start.min(editor.text().len()))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings line `line` into view and focus, caret at start.
|
||||
pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.commit(cx);
|
||||
let lines = self.editor.read(cx).buffer.lines();
|
||||
let at = self
|
||||
.editor
|
||||
.read(cx)
|
||||
.buffer
|
||||
.line_range(line.min(lines - 1))
|
||||
.start;
|
||||
self.rest = at;
|
||||
match self.mode {
|
||||
MarkdownMode::Source | MarkdownMode::Split => self
|
||||
.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx)),
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => {
|
||||
let blocks = &self.parsed.blocks;
|
||||
let ix = blocks
|
||||
.partition_point(|block| block.range.start <= at)
|
||||
.saturating_sub(1);
|
||||
self.list.scroll_to_reveal_item(ix);
|
||||
}
|
||||
}
|
||||
let focus = self.focus_handle(cx);
|
||||
window.focus(&focus, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn changed(&mut self, cx: &mut Context<Self>) {
|
||||
self.edits += 1;
|
||||
cx.emit(DocumentEvent::Changed);
|
||||
self.reparse(cx);
|
||||
}
|
||||
|
||||
/// Reads the text again, at once when short.
|
||||
fn reparse(&mut self, cx: &mut Context<Self>) {
|
||||
let (text, edits) = (self.editor.read(cx).text().to_string(), self.edits);
|
||||
if text.len() <= AT_ONCE {
|
||||
self._parsing = None;
|
||||
return self.adopt(parsed(&text), edits, cx);
|
||||
}
|
||||
self._parsing = Some(cx.spawn(async move |document, cx| {
|
||||
let read = cx.background_spawn(async move { parsed(&text) }).await;
|
||||
document
|
||||
.update(cx, |document, cx| document.adopt(read, edits, cx))
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
/// Takes the read of edit `edits`; older reads drop.
|
||||
fn adopt(&mut self, read: Parsed, edits: u64, cx: &mut Context<Self>) {
|
||||
if edits != self.edits {
|
||||
return log::debug!("markdown document: read {edits} lapsed");
|
||||
}
|
||||
let (head, tail) = alike(&self.parsed, &read);
|
||||
let old = self.parsed.blocks.len();
|
||||
self.list
|
||||
.splice(head..old - tail, read.blocks.len() - head - tail);
|
||||
self.parsed = Arc::new(read);
|
||||
self.read = edits;
|
||||
cx.emit(DocumentEvent::Parsed);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Closes the open block without writing it.
|
||||
fn drop_open(&mut self, cx: &mut Context<Self>) {
|
||||
if self.open.take().is_some() {
|
||||
log::info!("markdown document: the open block closed unwritten");
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast;
|
||||
|
||||
use crate::documents::{
|
||||
markdown::{file_tree, span, walk},
|
||||
tree::{Node, Reader},
|
||||
};
|
||||
|
||||
/// A top-level block: its source, and what it draws.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Block {
|
||||
pub range: Range<usize>,
|
||||
/// None shows the source as written: definitions, HTML, front matter.
|
||||
pub nodes: Option<Vec<Node>>,
|
||||
}
|
||||
|
||||
/// A heading anywhere in the file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DocumentHeading {
|
||||
pub depth: u8,
|
||||
pub title: String,
|
||||
/// Where its source starts, in bytes.
|
||||
pub at: usize,
|
||||
}
|
||||
|
||||
/// A file read as blocks and headings.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Parsed {
|
||||
/// The text read, to compare blocks across reads.
|
||||
pub text: String,
|
||||
pub blocks: Vec<Block>,
|
||||
pub headings: Vec<DocumentHeading>,
|
||||
}
|
||||
|
||||
/// `text` read whole; every byte is in or between blocks.
|
||||
pub(crate) fn parsed(text: &str) -> Parsed {
|
||||
let root = file_tree(text);
|
||||
let reader = Reader::new(text, &root);
|
||||
let blocks = root
|
||||
.children()
|
||||
.expect("the root holds the blocks")
|
||||
.iter()
|
||||
.map(|node| {
|
||||
let range = span(node);
|
||||
let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len();
|
||||
let raw = matches!(
|
||||
node,
|
||||
mdast::Node::Definition(_)
|
||||
| mdast::Node::Html(_)
|
||||
| mdast::Node::Yaml(_)
|
||||
| mdast::Node::Toml(_)
|
||||
);
|
||||
let nodes = (!raw).then(|| {
|
||||
let mut out = Vec::new();
|
||||
reader.block(node, &mut out);
|
||||
out
|
||||
});
|
||||
Block {
|
||||
range: range.start..range.start + kept,
|
||||
nodes,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
let headings = nodes
|
||||
.into_iter()
|
||||
.filter_map(|node| match node {
|
||||
mdast::Node::Heading(heading) => Some(DocumentHeading {
|
||||
depth: heading.depth,
|
||||
title: reader.inline(&heading.children).text,
|
||||
at: span(node).start,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Parsed {
|
||||
text: text.to_string(),
|
||||
blocks,
|
||||
headings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks alike at both ends: `(head, tail)` counts to keep.
|
||||
pub(crate) fn alike(old: &Parsed, new: &Parsed) -> (usize, usize) {
|
||||
let same = |a: &Block, b: &Block| {
|
||||
a.nodes == b.nodes && old.text[a.range.clone()] == new.text[b.range.clone()]
|
||||
};
|
||||
let head = old
|
||||
.blocks
|
||||
.iter()
|
||||
.zip(&new.blocks)
|
||||
.take_while(|(a, b)| same(a, b))
|
||||
.count();
|
||||
let room = old.blocks.len().min(new.blocks.len()) - head;
|
||||
let tail = old
|
||||
.blocks
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(new.blocks.iter().rev())
|
||||
.take(room)
|
||||
.take_while(|(a, b)| same(a, b))
|
||||
.count();
|
||||
(head, tail)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_block_is_shown_raw_ones_as_written() {
|
||||
let text = "---\ntitle: a\n---\n\n# Lift\n\n[site]: https://ely.dev\n\n<div>x</div>\n\n> ## Quoted\n";
|
||||
let found = parsed(text);
|
||||
let shown: Vec<(&str, bool)> = found
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|block| (&text[block.range.clone()], block.nodes.is_some()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shown,
|
||||
[
|
||||
("---\ntitle: a\n---", false),
|
||||
("# Lift", true),
|
||||
("[site]: https://ely.dev", false),
|
||||
("<div>x</div>", false),
|
||||
("> ## Quoted", true),
|
||||
]
|
||||
);
|
||||
let headings: Vec<(u8, &str, usize)> = found
|
||||
.headings
|
||||
.iter()
|
||||
.map(|heading| (heading.depth, heading.title.as_str(), heading.at))
|
||||
.collect();
|
||||
assert_eq!(headings, [(1, "Lift", 18), (2, "Quoted", 67)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_keeps_the_blocks_around_it() {
|
||||
let old = parsed("# A\n\nb\n\nc\n\nd\n");
|
||||
let new = parsed("# A\n\nb and more\n\nc\n\nd\n");
|
||||
assert_eq!(alike(&old, &new), (1, 2));
|
||||
assert_eq!(alike(&old, &old), (4, 0), "all kept from the head");
|
||||
let shorter = parsed("# A\n");
|
||||
assert_eq!(alike(&old, &shorter), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_resolves_across_blocks() {
|
||||
let found = parsed("[site]\n\n[site]: https://ely.dev\n");
|
||||
let Some(nodes) = &found.blocks[0].nodes else {
|
||||
panic!("the paragraph draws")
|
||||
};
|
||||
let Node::Paragraph(text) = &nodes[0] else {
|
||||
panic!("a paragraph, not {nodes:?}")
|
||||
};
|
||||
assert_eq!(text.links, [(0..4, "https://ely.dev".to_string())]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{AT_ONCE, DocumentEvent, MarkdownDocument, visual};
|
||||
use crate::documents::MarkdownMode;
|
||||
|
||||
const TEXT: &str = "# Lift\n\nBlend toward white.\n\n- one\n- two\n";
|
||||
|
||||
fn open<'a>(
|
||||
text: &str,
|
||||
mode: MarkdownMode,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<MarkdownDocument>, &'a mut VisualTestContext) {
|
||||
cx.update(crate::init_for_tests);
|
||||
let text = text.to_string();
|
||||
let (document, cx) =
|
||||
cx.add_window_view(move |window, cx| MarkdownDocument::new(text, mode, window, cx));
|
||||
cx.update(|window, _| window.activate_window());
|
||||
cx.run_until_parked();
|
||||
(document, cx)
|
||||
}
|
||||
|
||||
/// Presses block `ix` where it is drawn.
|
||||
fn press(ix: usize, modifiers: Modifiers, cx: &mut VisualTestContext) {
|
||||
let bounds = cx
|
||||
.debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str()))
|
||||
.expect("the block is drawn");
|
||||
let at = point(bounds.left() + px(4.0), bounds.top() + px(4.0));
|
||||
cx.simulate_mouse_down(at, MouseButton::Left, modifiers);
|
||||
cx.simulate_mouse_up(at, MouseButton::Left, modifiers);
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn text(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> String {
|
||||
document.read_with(cx, |document, cx| document.text(cx))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_block_typed_in_is_the_text_at_once_and_one_undo_step(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
let changes = Rc::new(RefCell::new(0));
|
||||
let counted = changes.clone();
|
||||
cx.update(|_, cx| {
|
||||
cx.subscribe(&document, move |_, event: &DocumentEvent, _| {
|
||||
if *event == DocumentEvent::Changed {
|
||||
*counted.borrow_mut() += 1;
|
||||
}
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
press(1, Modifiers::none(), cx);
|
||||
cx.simulate_input(" Again.");
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
"# Lift\n\nBlend toward white. Again.\n\n- one\n- two\n",
|
||||
"typing shows in the text before it is written back"
|
||||
);
|
||||
assert!(*changes.borrow() > 0, "each key tells the owner");
|
||||
cx.simulate_keystrokes("escape");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
assert_eq!(
|
||||
editor.read_with(cx, |editor, _| editor.text().to_string()),
|
||||
text(&document, cx),
|
||||
"written back"
|
||||
);
|
||||
cx.dispatch_action(visual::Undo);
|
||||
assert_eq!(text(&document, cx), TEXT, "one step undoes the block");
|
||||
cx.dispatch_action(visual::Redo);
|
||||
assert!(text(&document, cx).contains("Again."));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_same_text_and_history_cross_modes(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Source, cx);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| {
|
||||
editor.edit([(0..0, "Intro\n\n".to_string())], cx)
|
||||
});
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Visual, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), format!("Intro\n\n{TEXT}"));
|
||||
cx.dispatch_action(visual::Undo);
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
TEXT,
|
||||
"the source edit undoes in visual"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_mode_switch_writes_the_open_block_and_changes_nothing_else(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Preview, window, cx)
|
||||
});
|
||||
assert_eq!(
|
||||
text(&document, cx),
|
||||
TEXT,
|
||||
"an unchanged block writes nothing"
|
||||
);
|
||||
let undone = document.read_with(cx, |document, cx| {
|
||||
document.editor().read(cx).history.can_undo()
|
||||
});
|
||||
assert!(!undone, "no step was recorded");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn preview_and_a_secondary_press_open_no_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Preview, cx);
|
||||
press(1, Modifiers::none(), cx);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
document.update_in(cx, |document, window, cx| {
|
||||
document.set_mode(MarkdownMode::Visual, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
press(1, Modifiers::secondary_key(), cx);
|
||||
assert!(
|
||||
document.read_with(cx, |document, _| document.open.is_none()),
|
||||
"Cmd or Ctrl leaves the press to links"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn down_at_a_blocks_last_line_opens_the_next(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
cx.simulate_keystrokes("down");
|
||||
let opened = document.read_with(cx, |document, cx| {
|
||||
let open = document.open.as_ref().expect("a block is open");
|
||||
document.text(cx)[open.range.clone()].to_string()
|
||||
});
|
||||
assert_eq!(opened, "Blend toward white.");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn reveal_puts_the_caret_on_the_line(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Source, cx);
|
||||
document.update_in(cx, |document, window, cx| document.reveal(4, window, cx));
|
||||
assert_eq!(
|
||||
document.read_with(cx, |document, cx| document.caret(cx)),
|
||||
(4, 0)
|
||||
);
|
||||
cx.simulate_input("x");
|
||||
assert!(
|
||||
text(&document, cx).contains(
|
||||
"
|
||||
x- one"
|
||||
),
|
||||
"keys land on the line"
|
||||
);
|
||||
let outline: Vec<(String, usize)> = document.read_with(cx, |document, cx| {
|
||||
document
|
||||
.outline(cx)
|
||||
.into_iter()
|
||||
.map(|(heading, line)| (heading.title, line))
|
||||
.collect()
|
||||
});
|
||||
assert_eq!(outline, [("Lift".to_string(), 0)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn reset_starts_undo_afresh(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
|
||||
document.update(cx, |document, cx| document.set_text("changed", cx));
|
||||
document.update(cx, |document, cx| document.reset("from disk", cx));
|
||||
cx.dispatch_action(visual::Undo);
|
||||
assert_eq!(text(&document, cx), "from disk");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_long_text_reads_off_the_main_thread(cx: &mut TestAppContext) {
|
||||
let long = "A paragraph of prose.\n\n".repeat(AT_ONCE / 20);
|
||||
let (document, cx) = open(&long, MarkdownMode::Visual, cx);
|
||||
let (read, blocks) = document.read_with(cx, |document, _| {
|
||||
(document.read_now(), document.parsed.blocks.len())
|
||||
});
|
||||
assert!(read, "read once parked");
|
||||
assert_eq!(blocks, AT_ONCE / 20);
|
||||
document.update(cx, |document, cx| document.set_text("# Short\n", cx));
|
||||
assert!(
|
||||
document.read_with(cx, |document, _| document.read_now()),
|
||||
"a short text reads at once"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_past_the_end_adds_a_block(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("# Lift", MarkdownMode::Visual, cx);
|
||||
let end = cx.debug_bounds("markdown-block-0").expect("drawn").bottom() + px(12.0);
|
||||
cx.simulate_mouse_down(point(px(40.0), end), MouseButton::Left, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
cx.simulate_input("More.");
|
||||
assert_eq!(text(&document, cx), "# Lift\n\nMore.");
|
||||
cx.simulate_keystrokes("escape");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
let written = editor.read_with(cx, |editor, _| editor.text().to_string());
|
||||
assert_eq!(written, "# Lift\n\nMore.");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn front_matter_opens_as_written(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("---\ntitle: a\n---\n\nBody\n", MarkdownMode::Visual, cx);
|
||||
press(0, Modifiers::none(), cx);
|
||||
let opened = document.read_with(cx, |document, cx| {
|
||||
document
|
||||
.open
|
||||
.as_ref()
|
||||
.expect("open")
|
||||
.input
|
||||
.read(cx)
|
||||
.text()
|
||||
.to_string()
|
||||
});
|
||||
assert_eq!(opened, "---\ntitle: a\n---");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_source_colors_markdown(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(TEXT, MarkdownMode::Source, cx);
|
||||
let colors = document.read_with(cx, |document, cx| {
|
||||
document.editor().read(cx).line_colors("a **b**", cx)
|
||||
});
|
||||
assert!(
|
||||
colors.iter().any(|(range, style)| *range == (2..7)
|
||||
&& style.font_weight == Some(gpui::FontWeight::SEMIBOLD)),
|
||||
"{colors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn down_past_the_last_block_keeps_it_open(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("# Lift\n\nLast.", MarkdownMode::Visual, cx);
|
||||
press(1, Modifiers::none(), cx);
|
||||
cx.simulate_keystrokes("down");
|
||||
let opened = document.read_with(cx, |document, cx| {
|
||||
let open = document.open.as_ref().expect("still open");
|
||||
document.text(cx)[open.range.clone()].to_string()
|
||||
});
|
||||
assert_eq!(opened, "Last.");
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Focusable as _, FontWeight, InteractiveElement,
|
||||
IntoElement, KeyBinding, MouseButton, ParentElement, Render, Styled, Window, actions, div,
|
||||
list,
|
||||
};
|
||||
|
||||
use super::{DocumentEvent, MarkdownDocument, Open};
|
||||
use crate::{
|
||||
documents::{
|
||||
MarkdownMode,
|
||||
markdown::markdown_highlights,
|
||||
modes::{at_edge, joint},
|
||||
render::draw_nodes,
|
||||
},
|
||||
forms::{Down, InputEvent, TextInput, Up},
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
};
|
||||
|
||||
actions!(markdown_document, [Undo, Redo]);
|
||||
|
||||
const CONTEXT: &str = "MarkdownDocument";
|
||||
|
||||
/// Undo and redo while no block is open.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
let context = Some(CONTEXT);
|
||||
let mut bindings = vec![
|
||||
KeyBinding::new("secondary-z", Undo, context),
|
||||
KeyBinding::new("secondary-shift-z", Redo, context),
|
||||
];
|
||||
if !cfg!(target_os = "macos") {
|
||||
bindings.push(KeyBinding::new("ctrl-y", Redo, context));
|
||||
}
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
|
||||
impl MarkdownDocument {
|
||||
/// Opens block `ix`, or a new one past the end.
|
||||
pub(super) fn open(
|
||||
&mut self,
|
||||
ix: Option<usize>,
|
||||
at_start: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.commit(cx);
|
||||
if !self.read_now() {
|
||||
return log::warn!("markdown document: pressed while the text is read again");
|
||||
}
|
||||
let text = self.editor.read(cx).text();
|
||||
let (range, append) = match ix {
|
||||
Some(ix) => (self.parsed.blocks[ix].range.clone(), false),
|
||||
None => (text.len()..text.len(), true),
|
||||
};
|
||||
let source = text[range.clone()].to_string();
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.multi_line(1, usize::MAX)
|
||||
.highlighter(markdown_highlights)
|
||||
});
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_text(source, cx);
|
||||
if at_start {
|
||||
input.select(0..0, cx);
|
||||
}
|
||||
});
|
||||
window.focus(&input.focus_handle(cx), cx);
|
||||
let subscriptions =
|
||||
vec![
|
||||
cx.subscribe(&input, |document, _, event: &InputEvent, cx| match event {
|
||||
InputEvent::Blur => document.commit(cx),
|
||||
InputEvent::Changed => cx.emit(DocumentEvent::Changed),
|
||||
_ => {}
|
||||
}),
|
||||
];
|
||||
log::info!("markdown document: block {range:?} opens");
|
||||
self.open = Some(Open {
|
||||
range,
|
||||
input,
|
||||
append,
|
||||
_subscriptions: subscriptions,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Writes the open block back as one undo step.
|
||||
pub(super) fn commit(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(open) = self.open.take() else {
|
||||
return;
|
||||
};
|
||||
cx.notify();
|
||||
let typed = open.input.read(cx).text().to_string();
|
||||
let text = self.editor.read(cx).text();
|
||||
let written = match open.append {
|
||||
true if typed.is_empty() => return,
|
||||
true => format!("{}{typed}", joint(text)),
|
||||
false => typed,
|
||||
};
|
||||
self.rest = open.range.start + written.len();
|
||||
if text[open.range.clone()] == written {
|
||||
return;
|
||||
}
|
||||
log::info!("markdown document: block {:?} written back", open.range);
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.edit([(open.range, written)], cx));
|
||||
}
|
||||
|
||||
/// Up past the open block's top, or down past it.
|
||||
fn step(&mut self, down: bool, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
let Some(open) = &self.open else {
|
||||
return false;
|
||||
};
|
||||
if open.append || !at_edge(open.input.read(cx), down) {
|
||||
return false;
|
||||
}
|
||||
let start = open.range.start;
|
||||
let here = |blocks: &[super::parse::Block]| {
|
||||
blocks.partition_point(|block| block.range.start <= start)
|
||||
};
|
||||
// The block next door, before writing back.
|
||||
let next = |blocks: &[super::parse::Block]| match down {
|
||||
true => (here(blocks) < blocks.len()).then(|| here(blocks)),
|
||||
false => here(blocks).checked_sub(2),
|
||||
};
|
||||
if next(&self.parsed.blocks).is_none() {
|
||||
return false;
|
||||
}
|
||||
self.commit(cx);
|
||||
let Some(target) = next(&self.parsed.blocks) else {
|
||||
return false;
|
||||
};
|
||||
self.open(Some(target), down, window, cx);
|
||||
true
|
||||
}
|
||||
|
||||
/// List item `ix`: a block, or the end.
|
||||
fn item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let (radius, body, mono) = (
|
||||
theme.radius(Radius::Md),
|
||||
theme.text_size(TextSize::Base),
|
||||
theme.mono_family.clone(),
|
||||
);
|
||||
let editing = self.mode == MarkdownMode::Visual;
|
||||
let Some(block) = self.parsed.blocks.get(ix) else {
|
||||
return match (&self.open, editing) {
|
||||
(Some(open), _) if open.append => div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(radius)
|
||||
.bg(colors.sunken)
|
||||
.child(open.input.clone())
|
||||
.into_any_element(),
|
||||
(_, true) => div()
|
||||
.id("more")
|
||||
.min_h_8()
|
||||
.cursor_text()
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|document, _, window, cx| {
|
||||
window.prevent_default();
|
||||
document.open(None, false, window, cx);
|
||||
}),
|
||||
)
|
||||
.into_any_element(),
|
||||
(_, false) => div().into_any_element(),
|
||||
};
|
||||
};
|
||||
if let Some(open) = self.open.as_ref().filter(|open| open.range == block.range) {
|
||||
return div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(radius)
|
||||
.bg(colors.sunken)
|
||||
.child(open.input.clone())
|
||||
.into_any_element();
|
||||
}
|
||||
let shown = match &block.nodes {
|
||||
Some(nodes) => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.text_size(body)
|
||||
.line_height(body * 1.6)
|
||||
.text_color(colors.fg)
|
||||
.children(draw_nodes(
|
||||
&("render", ix).into(),
|
||||
nodes,
|
||||
&self.base,
|
||||
&self.on_link,
|
||||
cx,
|
||||
)),
|
||||
None => div()
|
||||
.font_family(mono)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.parsed.text[block.range.clone()].to_string()),
|
||||
};
|
||||
let block = div()
|
||||
.id(("block", ix))
|
||||
.debug_selector(move || format!("markdown-block-{ix}"))
|
||||
.px_3()
|
||||
.py_1()
|
||||
.rounded(radius)
|
||||
.child(shown);
|
||||
if !editing {
|
||||
return block.into_any_element();
|
||||
}
|
||||
block
|
||||
.cursor_text()
|
||||
.hover(|block| block.bg(colors.hover.opacity(0.5)))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |document, event: &gpui::MouseDownEvent, window, cx| {
|
||||
// Cmd or Ctrl leaves the press to a link.
|
||||
if event.modifiers.secondary() {
|
||||
return;
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
document.open(Some(ix), false, window, cx);
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn blocks(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
list(self.list.clone(), cx.processor(Self::item))
|
||||
.size_full()
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for MarkdownDocument {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let root = div()
|
||||
.size_full()
|
||||
.bg(colors.surface)
|
||||
.font_weight(FontWeight::NORMAL);
|
||||
match self.mode {
|
||||
MarkdownMode::Source => root.child(self.editor.clone()).into_any_element(),
|
||||
MarkdownMode::Split => root
|
||||
.flex()
|
||||
.child(div().flex_1().min_w_0().h_full().child(self.editor.clone()))
|
||||
.child(div().flex_1().min_w_0().h_full().child(self.blocks(cx)))
|
||||
.into_any_element(),
|
||||
MarkdownMode::Visual | MarkdownMode::Preview => root
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.on_action(cx.listener(|document, _: &Undo, _, cx| {
|
||||
document.commit(cx);
|
||||
document.editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
}))
|
||||
.on_action(cx.listener(|document, _: &Redo, _, cx| {
|
||||
document.commit(cx);
|
||||
document.editor.update(cx, |editor, cx| editor.redo(cx));
|
||||
}))
|
||||
.capture_action(cx.listener(|document, _: &Up, window, cx| {
|
||||
if document.step(false, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.capture_action(cx.listener(|document, _: &Down, window, cx| {
|
||||
if document.step(true, window, cx) {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.on_key_down(
|
||||
cx.listener(|document, event: &gpui::KeyDownEvent, window, cx| {
|
||||
if event.keystroke.key == "escape" && document.open.is_some() {
|
||||
cx.stop_propagation();
|
||||
document.commit(cx);
|
||||
window.focus(&document.focus, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.px_2()
|
||||
.py_3()
|
||||
.child(self.blocks(cx))
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,13 @@ pub(crate) fn tree(text: &str) -> mdast::Node {
|
||||
markdown::to_mdast(text, &options()).expect("markdown without MDX always parses")
|
||||
}
|
||||
|
||||
/// A whole file as a syntax tree: front matter too.
|
||||
pub(crate) fn file_tree(text: &str) -> mdast::Node {
|
||||
let mut options = options();
|
||||
options.constructs.frontmatter = true;
|
||||
markdown::to_mdast(text, &options).expect("markdown without MDX always parses")
|
||||
}
|
||||
|
||||
/// The bytes of the source a parsed node spans.
|
||||
pub(crate) fn span(node: &mdast::Node) -> Range<usize> {
|
||||
let at = node.position().expect("a parsed node has its position");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
mod blocks;
|
||||
mod contents;
|
||||
mod counts;
|
||||
mod diagram;
|
||||
mod document;
|
||||
mod epub;
|
||||
mod find;
|
||||
mod format;
|
||||
@@ -29,8 +31,10 @@ use gpui::App;
|
||||
|
||||
pub(crate) use blocks::source;
|
||||
pub use blocks::{Align, BlockData, BlockEditor, BlockEvent, BlockKind, Media};
|
||||
pub use contents::outline;
|
||||
pub use counts::{ReadingTime, WordCount};
|
||||
pub use diagram::Diagram;
|
||||
pub use document::{DocumentEvent, DocumentHeading, MarkdownDocument};
|
||||
pub use epub::{Chapter, EpubReader};
|
||||
pub use format::{Format, format};
|
||||
pub(crate) use history::Change;
|
||||
@@ -42,7 +46,7 @@ pub use modes::{MarkdownEditor, MarkdownMode};
|
||||
pub use pages::{PageCover, PageIcon};
|
||||
pub use print::{Margins, Paper, PrintPreview, PrintSettings, sheet};
|
||||
pub use reading::{Footnote, Glossary, ReadingProgress, TooltipTerm, ZenMode};
|
||||
pub use render::{MarkdownRenderer, outline};
|
||||
pub use render::MarkdownRenderer;
|
||||
pub use rich::RichTextEditor;
|
||||
pub use templates::{Template, TemplatePicker};
|
||||
pub use thumbs::PageThumbnailList;
|
||||
@@ -54,4 +58,5 @@ pub use viewer::{DocPage, DocumentViewer, PageHit, PageNote};
|
||||
/// Binds the documents keys. `init` calls it.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
rich::bind_keys(cx);
|
||||
document::bind_keys(cx);
|
||||
}
|
||||
+15
-2
@@ -29,13 +29,16 @@ pub enum MarkdownMode {
|
||||
Source,
|
||||
/// The source beside the document it makes.
|
||||
Split,
|
||||
/// The document alone, read only.
|
||||
Preview,
|
||||
}
|
||||
|
||||
impl MarkdownMode {
|
||||
const ALL: [(Self, &'static str); 3] = [
|
||||
const ALL: [(Self, &'static str); 4] = [
|
||||
(Self::Visual, "Visual"),
|
||||
(Self::Source, "Source"),
|
||||
(Self::Split, "Split"),
|
||||
(Self::Preview, "Preview"),
|
||||
];
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
@@ -76,7 +79,7 @@ fn shifted(range: Range<usize>, replaced: &Range<usize>, written: usize) -> Rang
|
||||
}
|
||||
|
||||
/// What joins a block added at the end to the text before it.
|
||||
fn joint(document: &str) -> &'static str {
|
||||
pub(crate) fn joint(document: &str) -> &'static str {
|
||||
if document.is_empty() || document.ends_with("\n\n") {
|
||||
""
|
||||
} else if document.ends_with('\n') {
|
||||
@@ -250,6 +253,16 @@ impl RenderOnce for MarkdownEditor {
|
||||
.into_any_element()
|
||||
}
|
||||
MarkdownMode::Visual => visual(self, state, switch, window, cx),
|
||||
MarkdownMode::Preview => {
|
||||
let text = SharedString::from(self.field.read(cx).text().to_string());
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(div().flex().justify_end().child(switch))
|
||||
.child(MarkdownRenderer::new((self.id, "preview"), text))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,3 +110,41 @@ fn a_press_on_a_block_of_a_replaced_text_opens_nothing(cx: &mut gpui::TestAppCon
|
||||
"x"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
struct Previewed(gpui::Entity<TextInput>);
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
impl gpui::Render for Previewed {
|
||||
fn render(
|
||||
&mut self,
|
||||
_: &mut gpui::Window,
|
||||
_: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
MarkdownEditor::new("preview", &self.0, MarkdownMode::Preview)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
#[gpui::test]
|
||||
fn preview_draws_the_document_and_takes_no_press(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(crate::init_for_tests);
|
||||
let (view, cx) = cx.add_window_view(|window, cx| {
|
||||
Previewed(cx.new(|cx| {
|
||||
let mut field = TextInput::new(window, cx).multi_line(1, usize::MAX);
|
||||
field.set_text("# Lift", cx);
|
||||
field
|
||||
}))
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let field = view.read_with(cx, |view, _| view.0.clone());
|
||||
cx.simulate_click(
|
||||
gpui::point(gpui::px(20.0), gpui::px(60.0)),
|
||||
gpui::Modifiers::none(),
|
||||
);
|
||||
assert_eq!(
|
||||
field.read_with(cx, |field, _| field.text().to_string()),
|
||||
"# Lift"
|
||||
);
|
||||
assert!(!cx.update(|window, cx| field.focus_handle(cx).is_focused(window)));
|
||||
}
|
||||
+20
-57
@@ -8,6 +8,7 @@ use gpui::{
|
||||
use markdown::mdast::AlignKind;
|
||||
|
||||
use super::{
|
||||
contents::anchor,
|
||||
diagram::Diagram,
|
||||
picture::Picture,
|
||||
tree::{Inline, Node, Style, parse},
|
||||
@@ -20,7 +21,7 @@ use crate::{
|
||||
typography::{Latex, failed},
|
||||
};
|
||||
|
||||
type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||
pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||
type DrawCode = Rc<dyn Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement>;
|
||||
|
||||
/// Markdown as a document: headings, prose with its styles and links, lists and tasks, quotes, code colored as code, tables, rules, math and footnotes. A press on a link opens it, or hands it to the host.
|
||||
@@ -401,37 +402,24 @@ fn open_link(on_link: &Option<OnLink>, url: &str, window: &mut Window, cx: &mut
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchor of the document's `ordinal`th heading.
|
||||
fn anchor(ordinal: usize) -> SharedString {
|
||||
format!("heading-{ordinal}").into()
|
||||
}
|
||||
|
||||
/// The headings of `markdown` in order, as a table of contents takes them: anchor, text and depth from 0.
|
||||
pub fn outline(markdown: &str) -> Vec<(SharedString, SharedString, usize)> {
|
||||
fn walk(nodes: &[Node], out: &mut Vec<(SharedString, SharedString, usize)>) {
|
||||
for node in nodes {
|
||||
match node {
|
||||
Node::Heading(level, inline) => {
|
||||
let ordinal = out.len();
|
||||
out.push((
|
||||
anchor(ordinal),
|
||||
inline.text.clone().into(),
|
||||
*level as usize - 1,
|
||||
));
|
||||
}
|
||||
Node::Quote(inner) | Node::Footnote { body: inner, .. } => walk(inner, out),
|
||||
Node::List { items, .. } => {
|
||||
for (_, body) in items {
|
||||
walk(body, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(&parse(markdown), &mut out);
|
||||
out
|
||||
/// Parsed `nodes` drawn under `id`; pictures read from `base`.
|
||||
pub(crate) fn draw_nodes(
|
||||
id: &ElementId,
|
||||
nodes: &[Node],
|
||||
base: &Option<Arc<Path>>,
|
||||
on_link: &Option<OnLink>,
|
||||
cx: &App,
|
||||
) -> Vec<AnyElement> {
|
||||
let draw = Draw {
|
||||
id,
|
||||
on_link,
|
||||
next: std::cell::Cell::new(0),
|
||||
sections: &None,
|
||||
headings: std::cell::Cell::new(0),
|
||||
code: &None,
|
||||
base,
|
||||
};
|
||||
draw.nodes(nodes, cx)
|
||||
}
|
||||
|
||||
impl RenderOnce for MarkdownRenderer {
|
||||
@@ -456,28 +444,3 @@ impl RenderOnce for MarkdownRenderer {
|
||||
.children(draw.nodes(&nodes, cx))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::outline;
|
||||
|
||||
#[test]
|
||||
fn the_outline_numbers_headings_in_order_with_their_depth() {
|
||||
let found =
|
||||
outline("# Lift\n\ntext\n\n## In light\n\n> ### Quoted\n\n- #### Listed\n\n## In dark");
|
||||
let shape: Vec<(&str, &str, usize)> = found
|
||||
.iter()
|
||||
.map(|(anchor, text, depth)| (anchor.as_ref(), text.as_ref(), *depth))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shape,
|
||||
[
|
||||
("heading-0", "Lift", 0),
|
||||
("heading-1", "In light", 1),
|
||||
("heading-2", "Quoted", 2),
|
||||
("heading-3", "Listed", 3),
|
||||
("heading-4", "In dark", 1),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+19
-18
@@ -59,7 +59,7 @@ pub enum Node {
|
||||
}
|
||||
|
||||
/// A tree read against its source and its link definitions.
|
||||
struct Reader<'a> {
|
||||
pub(crate) struct Reader<'a> {
|
||||
source: &'a str,
|
||||
urls: HashMap<String, String>,
|
||||
}
|
||||
@@ -97,28 +97,29 @@ impl Run {
|
||||
/// `markdown` as blocks.
|
||||
pub fn parse(markdown: &str) -> Vec<Node> {
|
||||
let root = tree(markdown);
|
||||
let mut nodes = Vec::new();
|
||||
walk(&root, &mut nodes);
|
||||
let mut urls = HashMap::new();
|
||||
for node in nodes {
|
||||
if let mdast::Node::Definition(definition) = node {
|
||||
urls.entry(definition.identifier.clone())
|
||||
.or_insert_with(|| definition.url.clone());
|
||||
}
|
||||
}
|
||||
let reader = Reader {
|
||||
source: markdown,
|
||||
urls,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
reader.blocks(
|
||||
Reader::new(markdown, &root).blocks(
|
||||
root.children().expect("the root holds the blocks"),
|
||||
&mut out,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
impl<'a> Reader<'a> {
|
||||
/// A reader of `root`, parsed from `source`.
|
||||
pub(crate) fn new(source: &'a str, root: &mdast::Node) -> Self {
|
||||
let mut nodes = Vec::new();
|
||||
walk(root, &mut nodes);
|
||||
let mut urls = HashMap::new();
|
||||
for node in nodes {
|
||||
if let mdast::Node::Definition(definition) = node {
|
||||
urls.entry(definition.identifier.clone())
|
||||
.or_insert_with(|| definition.url.clone());
|
||||
}
|
||||
}
|
||||
Self { source, urls }
|
||||
}
|
||||
|
||||
fn blocks(&self, nodes: &[mdast::Node], out: &mut Vec<Node>) {
|
||||
for node in nodes {
|
||||
self.block(node, out);
|
||||
@@ -139,7 +140,7 @@ impl Reader<'_> {
|
||||
.expect("a reference names a definition")
|
||||
}
|
||||
|
||||
fn block(&self, node: &mdast::Node, out: &mut Vec<Node>) {
|
||||
pub(crate) fn block(&self, node: &mdast::Node, out: &mut Vec<Node>) {
|
||||
match node {
|
||||
mdast::Node::Paragraph(paragraph) => self.paragraph(node, ¶graph.children, out),
|
||||
mdast::Node::Heading(heading) => {
|
||||
@@ -212,7 +213,7 @@ impl Reader<'_> {
|
||||
}
|
||||
|
||||
/// Inline text where images draw as their alt text.
|
||||
fn inline(&self, nodes: &[mdast::Node]) -> Inline {
|
||||
pub(crate) fn inline(&self, nodes: &[mdast::Node]) -> Inline {
|
||||
let mut run = Run::default();
|
||||
for node in nodes {
|
||||
self.inline_into(node, &mut run, &mut Images::Alt);
|
||||
|
||||
+1
-2
@@ -11,7 +11,6 @@ use super::{
|
||||
gutter::Cell,
|
||||
layout::{Shown, stack},
|
||||
state::{CodeEditor, CursorShape},
|
||||
syntax,
|
||||
};
|
||||
use crate::{
|
||||
charts::tint,
|
||||
@@ -29,7 +28,7 @@ impl CodeEditor {
|
||||
let touches = |span: &Range<usize>| {
|
||||
span.start < range.end.max(range.start + 1) && range.start < span.end
|
||||
};
|
||||
let mut spans = syntax::colors(self.row_text(line), cx);
|
||||
let mut spans = self.line_colors(self.row_text(line), cx);
|
||||
let brackets = self
|
||||
.frame
|
||||
.brackets
|
||||
|
||||
@@ -19,6 +19,7 @@ mod line;
|
||||
mod moves;
|
||||
mod preview;
|
||||
mod problems;
|
||||
mod prose;
|
||||
mod refactor;
|
||||
mod row;
|
||||
mod search;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use std::{fmt, ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{App, Context, FontStyle, HighlightStyle, StrikethroughStyle};
|
||||
|
||||
use super::{buffer::Buffer, cursor::Selection, state::CodeEditor, syntax};
|
||||
use crate::{
|
||||
editor::{EditorEvent, anchors},
|
||||
forms::{Highlight, History},
|
||||
};
|
||||
|
||||
/// Undo keeps at most this many bytes of past text.
|
||||
pub(crate) const HISTORY_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Colors for one line of text.
|
||||
type Paint = dyn Fn(&str, &App) -> Vec<(Range<usize>, Highlight)>;
|
||||
|
||||
/// The owner's colors for each line.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct LineHighlighter(pub Rc<Paint>);
|
||||
|
||||
impl fmt::Debug for LineHighlighter {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("LineHighlighter")
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeEditor {
|
||||
/// Colors each line with the owner's highlighter, not code's.
|
||||
pub fn highlighter(
|
||||
mut self,
|
||||
highlight: impl Fn(&str, &App) -> Vec<(Range<usize>, Highlight)> + 'static,
|
||||
) -> Self {
|
||||
self.options.highlighter = Some(LineHighlighter(Rc::new(highlight)));
|
||||
self
|
||||
}
|
||||
|
||||
/// New text with no history: undo starts here.
|
||||
pub fn reset(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
|
||||
self.buffer = Buffer::new(text);
|
||||
self.selections = vec![Selection::caret(0)];
|
||||
self.history = History::default();
|
||||
self.folded.clear();
|
||||
(self.marked, self.composing) = (None, None);
|
||||
anchors::clear(&mut self.marks);
|
||||
cx.emit(EditorEvent::Changed);
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
/// A line's colors, as the owner or as code.
|
||||
pub(crate) fn line_colors(&self, text: &str, cx: &App) -> Vec<(Range<usize>, HighlightStyle)> {
|
||||
let Some(LineHighlighter(highlight)) = &self.options.highlighter else {
|
||||
return syntax::colors(text, cx);
|
||||
};
|
||||
let rule = crate::theme::ActiveTheme::theme(cx).underline_thickness();
|
||||
highlight(text, cx)
|
||||
.into_iter()
|
||||
.map(|(range, highlight)| {
|
||||
let style = HighlightStyle {
|
||||
color: Some(highlight.color),
|
||||
background_color: highlight.background,
|
||||
font_weight: highlight.weight,
|
||||
font_style: highlight.italic.then_some(FontStyle::Italic),
|
||||
strikethrough: highlight.strike.then_some(StrikethroughStyle {
|
||||
thickness: rule,
|
||||
color: Some(highlight.color),
|
||||
}),
|
||||
..HighlightStyle::default()
|
||||
};
|
||||
(range, style)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -8,7 +8,6 @@ use super::{
|
||||
gutter::Cell,
|
||||
layout::{CHAT_ROWS, Row},
|
||||
state::{CodeEditor, EditorEvent},
|
||||
syntax,
|
||||
};
|
||||
use crate::theme::{ActiveTheme, TextSize};
|
||||
|
||||
@@ -121,7 +120,7 @@ impl CodeEditor {
|
||||
let line = self.buffer.line_of(ghost.offset);
|
||||
let rest =
|
||||
self.buffer.text()[ghost.offset..self.buffer.line_range(line).end].to_string();
|
||||
let styles = syntax::colors(&rest, cx);
|
||||
let styles = self.line_colors(&rest, cx);
|
||||
StyledText::new(rest).with_highlights(styles)
|
||||
});
|
||||
base.child(self.gutter(Cell::Blank, ix, cx))
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(crate) struct Options {
|
||||
pub sticky: bool,
|
||||
pub read_only: bool,
|
||||
pub tab: usize,
|
||||
pub highlighter: Option<super::prose::LineHighlighter>,
|
||||
}
|
||||
|
||||
/// What the owner lays over the code.
|
||||
@@ -186,6 +187,7 @@ impl CodeEditor {
|
||||
sticky: false,
|
||||
read_only: false,
|
||||
tab: 4,
|
||||
highlighter: None,
|
||||
},
|
||||
marks: Marks::default(),
|
||||
marked: None,
|
||||
@@ -430,6 +432,12 @@ impl CodeEditor {
|
||||
if before.text != self.buffer.text() {
|
||||
if self.composing.is_none() {
|
||||
self.history.record(before, typing);
|
||||
let dropped = self
|
||||
.history
|
||||
.trim(super::prose::HISTORY_BYTES, |step| step.text.len());
|
||||
if dropped > 0 {
|
||||
log::info!("code editor: {dropped} oldest undo steps dropped");
|
||||
}
|
||||
}
|
||||
self.marks.ghost = None;
|
||||
self.folded.retain(|line| *line < self.buffer.lines());
|
||||
|
||||
+3
-3
@@ -53,7 +53,7 @@ impl CodeEditor {
|
||||
}
|
||||
|
||||
/// The first line the list shows, after scrolling.
|
||||
fn top_line(&self) -> Option<usize> {
|
||||
pub(crate) fn top_line(&self) -> Option<usize> {
|
||||
let scrolled = -self.scroll.0.borrow().base_handle.offset().y;
|
||||
let first = (scrolled / self.frame.line).floor().max(0.0) as usize;
|
||||
self.frame.rows[first.min(self.frame.rows.len().saturating_sub(1))..]
|
||||
@@ -73,7 +73,7 @@ impl CodeEditor {
|
||||
}
|
||||
let rows = headers.into_iter().map(|line| {
|
||||
let text = self.buffer.line(line).to_string();
|
||||
let styles = syntax::colors(&text, cx);
|
||||
let styles = self.line_colors(&text, cx);
|
||||
let jump = cx.entity();
|
||||
div()
|
||||
.id(("sticky", line))
|
||||
@@ -131,7 +131,7 @@ impl CodeEditor {
|
||||
.map(|row| match row {
|
||||
Row::Line(at) => {
|
||||
let text = self.buffer.line(*at);
|
||||
syntax::colors(text, cx)
|
||||
self.line_colors(text, cx)
|
||||
.into_iter()
|
||||
.filter_map(|(range, style)| {
|
||||
let start = text[..range.start].chars().count();
|
||||
|
||||
@@ -143,12 +143,37 @@ impl<S> History<S> {
|
||||
self.typing_since = None;
|
||||
Some(next)
|
||||
}
|
||||
|
||||
/// Drops oldest undo steps past `budget`; returns how many.
|
||||
pub fn trim(&mut self, budget: usize, size: impl Fn(&S) -> usize) -> usize {
|
||||
let mut held: usize = self.undo.iter().chain(&self.redo).map(&size).sum();
|
||||
let mut dropped = 0;
|
||||
while held > budget && dropped < self.undo.len() {
|
||||
held -= size(&self.undo[dropped]);
|
||||
dropped += 1;
|
||||
}
|
||||
self.undo.drain(..dropped);
|
||||
dropped
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trimming_drops_the_oldest_steps_past_the_budget() {
|
||||
let mut history = History::default();
|
||||
for step in ["aaaa", "bbbb", "cccc"] {
|
||||
history.record(step.to_string(), false);
|
||||
}
|
||||
assert_eq!(history.trim(9, String::len), 1, "12 bytes over 9");
|
||||
assert_eq!(history.undo("now".into()), Some("cccc".to_string()));
|
||||
assert_eq!(history.undo("cccc".into()), Some("bbbb".to_string()));
|
||||
assert_eq!(history.undo("bbbb".into()), None, "the oldest went");
|
||||
assert_eq!(history.trim(100, String::len), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphemes_keep_clusters_whole() {
|
||||
let text = "ae\u{301}🇯🇵z";
|
||||
|
||||
Reference in new issue
Block a user