Right press: one alignment pass, latest press wins
CI / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 12m14s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

drawn_at aligns each source piece once instead of re-reading the span
for every drawn byte, so a right press near a long paragraph's start
stays linear. A newer press drops an older pending open-block lookup.

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-14 21:40:55 -04:00
1 parent a4bf6965f9
commit 2b8bd9fcf3
5 files changed
+99 -19

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. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `<code>` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `</br>` breaks as `<br>` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the listLine truncated
- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `<code>` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `</br>` breaks as `<br>` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the listLine truncated
- 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.
+3
View File
@@ -116,6 +116,8 @@ pub struct MarkdownDocument {
focus: FocusHandle,
/// A secondary press took focus.
pointing: bool,
/// The latest press; older lookups lapse.
pressed: u64,
on_link: Option<OnLink>,
on_heading: Option<OnHeading>,
_subscriptions: Vec<Subscription>,
@@ -182,6 +184,7 @@ impl MarkdownDocument {
room: Pixels::ZERO,
focus,
pointing: false,
pressed: 0,
on_link: None,
on_heading: Some(on_heading),
_subscriptions: subscriptions,
+7 -8
View File
@@ -111,6 +111,7 @@ impl MarkdownDocument {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.pressed += 1;
let block = match ix.map(|ix| self.parsed.blocks.get(ix)) {
Some(None) => return log::warn!("markdown document: a press on a block read away"),
block => block.flatten(),
@@ -170,12 +171,17 @@ impl MarkdownDocument {
let pressed = open.input.read(cx).offset_for_point(position);
let taken = self.snapshot(cx);
let (full, offset, _) = taken.clone();
self.pressed += 1;
let press = self.pressed;
cx.spawn(async move |document, cx| {
let target = cx
.background_spawn(async move { target_at(&full, offset + pressed) })
.await;
document
.update(cx, |document, cx| {
if document.pressed != press {
return log::info!("markdown document: a newer press came first");
}
if document.open.is_none() || document.snapshot(cx) != taken {
return log::info!("markdown document: the pressed block changed first");
}
@@ -291,14 +297,7 @@ fn drawn_link(nodes: &[&Node], text: &str, at: usize) -> Option<Option<String>>
}
_ => None,
})?;
let drawn = (0..inline.text.len())
.filter(|ix| inline.text.is_char_boundary(*ix))
.rev()
.find(|ix| {
inline
.source_at(text, *ix)
.is_some_and(|source| source <= at)
})?;
let drawn = inline.drawn_at(text, at)?;
let link = (inline.links.iter()).find(|(range, _)| range.contains(&drawn));
Some(link.map(|(_, url)| url.clone()))
}
+24
View File
@@ -162,6 +162,30 @@ fn a_press_in_a_selection_reads_the_pressed_link(cx: &mut TestAppContext) {
assert_eq!(link.as_deref(), Some("https://b.test"));
}
#[gpui::test]
fn only_the_latest_press_in_an_open_block_is_heard(cx: &mut TestAppContext) {
let (document, cx) = open(TWO, MarkdownMode::Visual, cx);
let heard = heard(&document, cx);
press_on(0, 1, MouseButton::Left, cx);
select_open(&document, 0..43, cx);
let spots = document.read_with(cx, |document, cx| {
let input = document.open.as_ref().expect("open").input.read(cx);
["one", "two"].map(|word| {
let at = TWO.find(word).expect("word") + 1;
input.bounds_for_range(at..at + 1)[0].center()
})
});
document.update(cx, |document, cx| {
document.point_open(spots[0], cx);
document.point_open(spots[1], cx);
});
cx.run_until_parked();
let links: Vec<_> = (heard.borrow().iter())
.map(|target| target.link.clone())
.collect();
assert_eq!(links, [Some("https://b.test".to_string())]);
}
const PICTURES: &str = "x ![a](a.png) ![b](b.png)\n";
#[gpui::test]
+64 -10
View File
@@ -196,6 +196,25 @@ impl Inline {
// Unreadable text lands at its start.
Some(span.start + align(drawn, written, *escapes, at - start).unwrap_or(0))
}
/// The last drawn byte written by source byte `at`.
pub(crate) fn drawn_at(&self, source: &str, at: usize) -> Option<usize> {
(0..self.sources.len()).rev().find_map(|piece| {
let (start, span, escapes) = &self.sources[piece];
if span.start > at {
return None;
}
let end = (self.sources.get(piece + 1)).map_or(self.text.len(), |(next, ..)| *next);
let drawn = self.text.get(*start..end.max(*start))?;
let written = source.get(span.clone())?;
// Unreadable text lands at its start.
let mut places = aligned(drawn, written, *escapes).into_iter();
(drawn.char_indices().rev())
.map(|(byte, _)| (byte, places.next().map_or(0, |(_, place)| place)))
.find(|(_, place)| span.start + place <= at)
.map(|(byte, _)| start + byte)
})
}
}
/// A source char as read, and where it was written.
@@ -285,11 +304,21 @@ fn align(drawn: &str, written: &str, escapes: Escapes, at: usize) -> Option<usiz
if at >= drawn.len() {
return Some(written.len());
}
(aligned(drawn, written, escapes).into_iter())
.find(|(byte, _)| *byte == at)
.map(|(_, place)| place)
}
/// Drawn chars from the end and where each was written.
fn aligned(drawn: &str, written: &str, escapes: Escapes) -> Vec<(usize, usize)> {
let source = read(written, escapes);
let mut j = source.len();
let mut out = Vec::new();
for (byte, d) in drawn.char_indices().rev() {
let found = loop {
let s = source[..j].last()?;
let Some(s) = source[..j].last() else {
return out;
};
if d == s.ch {
j -= 1;
break s.at;
@@ -307,29 +336,54 @@ fn align(drawn: &str, written: &str, escapes: Escapes, at: usize) -> Option<usiz
j -= 1;
continue;
}
return None;
return out;
};
if byte == at {
return Some(found);
}
out.push((byte, found));
}
None
out
}
#[cfg(test)]
mod tests {
use super::super::{Reader, tree};
use super::super::{Inline, Reader, tree};
/// The source byte under drawn text in the first paragraph.
fn under(source: &str, drawn: &str) -> usize {
/// The first paragraph, as drawn.
fn first(source: &str) -> Inline {
let root = tree(source);
let reader = Reader::new(source, &root);
let paragraph = &root.children().expect("blocks")[0];
let inline = reader.inline(paragraph.children().expect("inlines"));
reader.inline(paragraph.children().expect("inlines"))
}
/// The source byte under drawn text in the first paragraph.
fn under(source: &str, drawn: &str) -> usize {
let inline = first(source);
let at = inline.text.find(drawn).expect("drawn");
inline.source_at(source, at).expect("a piece")
}
#[test]
fn a_source_byte_reads_back_to_its_drawn_byte() {
let source = "Say **bold** and \\*x";
let inline = first(source);
let bold = source.find("bold").unwrap();
assert_eq!(inline.drawn_at(source, bold), inline.text.find("bold"));
let mark = source.find("**").unwrap();
assert_eq!(inline.drawn_at(source, mark), inline.text.find(' '));
let star = source.find("*x").unwrap();
assert_eq!(inline.drawn_at(source, star), inline.text.find('*'));
}
#[test]
fn a_long_paragraph_reads_back_in_one_pass() {
let source = "x".repeat(40_000);
let inline = first(&source);
let started = std::time::Instant::now();
assert_eq!(inline.drawn_at(&source, 5), Some(5));
let took = started.elapsed();
assert!(took.as_millis() < 500, "took {took:?}");
}
#[test]
fn a_word_lands_where_it_was_written() {
let source = "Say **bold** and `code` here\nnext line";