MarkdownDocument: a Markdown file in one CodeEditor, shown visual, source, split or preview
CI / site (push) Successful in 34s
CI / platforms (windows-latest) (push) Successful in 5m7s
CI / crate (push) Failing after 26m45s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 00:44:17 -04:00
1 parent 9036c92442
commit 2b6f5513aa
19 files changed
+1267 -86

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window.
- Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32.
- Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation.
- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. `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.
+61
View File
@@ -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),
]
);
}
}
+301
View File
@@ -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();
}
}
}
+160
View File
@@ -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())]);
}
}
+244
View File
@@ -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.");
}
+283
View File
@@ -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(),
}
}
}
+7
View File
@@ -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");
+6 -1
View File
@@ -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
View File
@@ -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()
}
}
}
}
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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, &paragraph.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
View File
@@ -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
+1
View File
@@ -19,6 +19,7 @@ mod line;
mod moves;
mod preview;
mod problems;
mod prose;
mod refactor;
mod row;
mod search;
+73
View File
@@ -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
View File
@@ -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))
+8
View File
@@ -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
View File
@@ -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();
+25
View File
@@ -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";