From 2b6f5513aade980983cefcc0ee17dba8843e398d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 00:44:17 -0400 Subject: [PATCH] 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) --- AGENTS.md | 2 +- src/documents/contents.rs | 61 +++++++ src/documents/document/mod.rs | 301 +++++++++++++++++++++++++++++++ src/documents/document/parse.rs | 160 ++++++++++++++++ src/documents/document/tests.rs | 244 +++++++++++++++++++++++++ src/documents/document/visual.rs | 283 +++++++++++++++++++++++++++++ src/documents/markdown.rs | 7 + src/documents/mod.rs | 7 +- src/documents/modes.rs | 17 +- src/documents/modes/tests.rs | 38 ++++ src/documents/render.rs | 77 ++------ src/documents/tree.rs | 37 ++-- src/editor/line.rs | 3 +- src/editor/mod.rs | 1 + src/editor/prose.rs | 73 ++++++++ src/editor/row.rs | 3 +- src/editor/state.rs | 8 + src/editor/view.rs | 6 +- src/forms/text/edit.rs | 25 +++ 19 files changed, 1267 insertions(+), 86 deletions(-) create mode 100644 src/documents/contents.rs create mode 100644 src/documents/document/mod.rs create mode 100644 src/documents/document/parse.rs create mode 100644 src/documents/document/tests.rs create mode 100644 src/documents/document/visual.rs create mode 100644 src/editor/prose.rs diff --git a/AGENTS.md b/AGENTS.md index b67c19e..382840d 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. `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. diff --git a/src/documents/contents.rs b/src/documents/contents.rs new file mode 100644 index 0000000..831a3db --- /dev/null +++ b/src/documents/contents.rs @@ -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), + ] + ); + } +} diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs new file mode 100644 index 0000000..7d76c10 --- /dev/null +++ b/src/documents/document/mod.rs @@ -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, + input: Entity, + /// Opened past the end, to add a block. + append: bool, + _subscriptions: Vec, +} + +/// A Markdown file: one text, one history, three views. +pub struct MarkdownDocument { + editor: Entity, + mode: MarkdownMode, + base: Option>, + parsed: Arc, + /// Text changes counted; `read` is the one parsed. + edits: u64, + read: u64, + _parsing: Option>, + list: ListState, + open: Option, + /// Where the caret rests while no block is open. + rest: usize, + focus: FocusHandle, + on_link: Option, + _subscriptions: Vec, +} + +impl EventEmitter 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, + mode: MarkdownMode, + window: &mut Window, + cx: &mut Context, + ) -> 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>, cx: &mut Context) { + 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 { + &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.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, cx: &mut Context) { + 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, cx: &mut Context) { + 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.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.edits += 1; + cx.emit(DocumentEvent::Changed); + self.reparse(cx); + } + + /// Reads the text again, at once when short. + fn reparse(&mut self, cx: &mut Context) { + 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) { + 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) { + if self.open.take().is_some() { + log::info!("markdown document: the open block closed unwritten"); + cx.notify(); + } + } +} diff --git a/src/documents/document/parse.rs b/src/documents/document/parse.rs new file mode 100644 index 0000000..0429fa0 --- /dev/null +++ b/src/documents/document/parse.rs @@ -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, + /// None shows the source as written: definitions, HTML, front matter. + pub nodes: Option>, +} + +/// 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, + pub headings: Vec, +} + +/// `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
x
\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), + ("
x
", 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())]); + } +} diff --git a/src/documents/document/tests.rs b/src/documents/document/tests.rs new file mode 100644 index 0000000..2a9a0dd --- /dev/null +++ b/src/documents/document/tests.rs @@ -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, &'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, 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."); +} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs new file mode 100644 index 0000000..61e16a2 --- /dev/null +++ b/src/documents/document/visual.rs @@ -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, + at_start: bool, + window: &mut Window, + cx: &mut Context, + ) { + 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) { + 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) -> 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) -> 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) -> 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) -> 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(), + } + } +} diff --git a/src/documents/markdown.rs b/src/documents/markdown.rs index 388aaad..2997b1f 100644 --- a/src/documents/markdown.rs +++ b/src/documents/markdown.rs @@ -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 { let at = node.position().expect("a parsed node has its position"); diff --git a/src/documents/mod.rs b/src/documents/mod.rs index f6958ce..73a060f 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -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); } diff --git a/src/documents/modes.rs b/src/documents/modes.rs index 9666328..5c6db2a 100644 --- a/src/documents/modes.rs +++ b/src/documents/modes.rs @@ -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, replaced: &Range, 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() + } } } } diff --git a/src/documents/modes/tests.rs b/src/documents/modes/tests.rs index 6341def..46e6fb0 100644 --- a/src/documents/modes/tests.rs +++ b/src/documents/modes/tests.rs @@ -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); + +#[cfg(feature = "test-support")] +impl gpui::Render for Previewed { + fn render( + &mut self, + _: &mut gpui::Window, + _: &mut gpui::Context, + ) -> 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))); +} diff --git a/src/documents/render.rs b/src/documents/render.rs index e41a538..41307f1 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -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; +pub(crate) type OnLink = Rc; type DrawCode = Rc, 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, 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>, + on_link: &Option, + cx: &App, +) -> Vec { + 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), - ] - ); - } -} diff --git a/src/documents/tree.rs b/src/documents/tree.rs index b556f81..f5d5051 100644 --- a/src/documents/tree.rs +++ b/src/documents/tree.rs @@ -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, } @@ -97,28 +97,29 @@ impl Run { /// `markdown` as blocks. pub fn parse(markdown: &str) -> Vec { 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) { 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) { + pub(crate) fn block(&self, node: &mdast::Node, out: &mut Vec) { 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); diff --git a/src/editor/line.rs b/src/editor/line.rs index 46f7fc2..af59f1c 100644 --- a/src/editor/line.rs +++ b/src/editor/line.rs @@ -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| { 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 diff --git a/src/editor/mod.rs b/src/editor/mod.rs index f8220f1..835e2de 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -19,6 +19,7 @@ mod line; mod moves; mod preview; mod problems; +mod prose; mod refactor; mod row; mod search; diff --git a/src/editor/prose.rs b/src/editor/prose.rs new file mode 100644 index 0000000..9bc8670 --- /dev/null +++ b/src/editor/prose.rs @@ -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, Highlight)>; + +/// The owner's colors for each line. +#[derive(Clone)] +pub(crate) struct LineHighlighter(pub Rc); + +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, 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, cx: &mut Context) { + 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, 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() + } +} diff --git a/src/editor/row.rs b/src/editor/row.rs index 84232e5..9ee0f37 100644 --- a/src/editor/row.rs +++ b/src/editor/row.rs @@ -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)) diff --git a/src/editor/state.rs b/src/editor/state.rs index a627dfa..8f7e5f7 100644 --- a/src/editor/state.rs +++ b/src/editor/state.rs @@ -63,6 +63,7 @@ pub(crate) struct Options { pub sticky: bool, pub read_only: bool, pub tab: usize, + pub highlighter: Option, } /// 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()); diff --git a/src/editor/view.rs b/src/editor/view.rs index c1dc7a7..bf4ac28 100644 --- a/src/editor/view.rs +++ b/src/editor/view.rs @@ -53,7 +53,7 @@ impl CodeEditor { } /// The first line the list shows, after scrolling. - fn top_line(&self) -> Option { + pub(crate) fn top_line(&self) -> Option { 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(); diff --git a/src/forms/text/edit.rs b/src/forms/text/edit.rs index 27ddaa2..5563808 100644 --- a/src/forms/text/edit.rs +++ b/src/forms/text/edit.rs @@ -143,12 +143,37 @@ impl History { 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";