documents: a press opens its block on the pressed character; a secondary press points

Each drawn text piece keeps its written span, so a press maps back to a
source byte. A secondary press opens nothing: it moves the caret there and
emits DocumentEvent::Pointed with the block's kind, link, picture and
whether it lies in a table or code. Block nodes count from their own start.

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 09:38:08 -04:00
1 parent 12512217bd
commit 5af11fe489
17 files changed
+813 -82

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
use std::ops::Range;
use markdown::mdast::Node;
use super::{Inline, Node as Drawn, span};
/// A leaf's written span; code and math inside their marks.
pub(super) fn written(node: &Node, source: &str) -> Option<Range<usize>> {
let whole = match node {
Node::Text(_)
| Node::Break(_)
| Node::Html(_)
| Node::FootnoteReference(_)
| Node::Image(_)
| Node::ImageReference(_)
| Node::InlineCode(_)
| Node::InlineMath(_) => span(node),
_ => return None,
};
let marks = match node {
Node::InlineCode(_) | Node::InlineMath(_) => {
let text = &source[whole.clone()];
text.len() - text.trim_start_matches(['`', '$']).len()
}
_ => 0,
};
Some(whole.start + marks..whole.end)
}
/// Where a fenced block's `text` starts, if written as drawn.
pub(super) fn code_from(source: &str, node: &Node, text: &str) -> Option<usize> {
let whole = span(node);
let written = &source[whole.clone()];
let body = written.find('\n')? + 1;
written[body..]
.starts_with(text)
.then_some(whole.start + body)
}
impl Drawn {
/// Its source bytes counted from `start`, its block's.
pub(crate) fn rebase(&mut self, start: usize) {
match self {
Drawn::Paragraph(inline) | Drawn::Heading(_, inline) => {
for (_, span) in &mut inline.sources {
*span = span.start - start..span.end - start;
}
}
Drawn::Quote(body) | Drawn::Alert { body, .. } | Drawn::Footnote { body, .. } => {
body.iter_mut().for_each(|node| node.rebase(start))
}
Drawn::List { items, .. } => items
.iter_mut()
.flat_map(|(_, body)| body)
.for_each(|node| node.rebase(start)),
Drawn::Table { head, rows, .. } => head
.iter_mut()
.chain(rows.iter_mut().flatten())
.flatten()
.for_each(|node| node.rebase(start)),
Drawn::Code { from, .. } => {
if let Some(from) = from {
*from -= start;
}
}
Drawn::Image { at, .. } => *at -= start,
Drawn::Rule | Drawn::Math(_) | Drawn::Toc(_) | Drawn::FrontMatter(_) => {}
}
}
}
impl Inline {
/// Source byte under drawn byte `at`, if a piece holds it.
pub(crate) fn source_at(&self, source: &str, at: usize) -> Option<usize> {
let piece = self.sources.partition_point(|(start, _)| *start <= at);
let (start, span) = self.sources.get(piece.checked_sub(1)?)?;
let end = self
.sources
.get(piece)
.map_or(self.text.len(), |(next, _)| *next);
let drawn = self.text.get(*start..end.max(*start))?;
let written = source.get(span.clone())?;
// Text that does not read back lands at its start.
Some(span.start + align(drawn, written, at - start).unwrap_or(0))
}
}
/// Where drawn byte `at` was written, past joins and escapes.
fn align(drawn: &str, written: &str, at: usize) -> Option<usize> {
let (mut shown, mut source) = (drawn.char_indices().peekable(), written.char_indices());
let mut next = source.next();
loop {
let here = next.map_or(written.len(), |(byte, _)| byte);
let Some(&(byte, shown_char)) = shown.peek() else {
return Some(here);
};
if byte >= at {
return Some(here);
}
let (_, written_char) = next?;
match (shown_char, written_char) {
(a, b) if a == b => {
shown.next();
}
(' ', '\r' | '\n') => {
shown.next();
next = source.next();
while let Some((_, ' ' | '\t' | '\r' | '\n')) = next {
next = source.next();
}
continue;
}
(_, ' ' | '\t' | '\r' | '\n' | '\\') => {}
_ => return None,
}
next = source.next();
}
}
#[cfg(test)]
mod tests {
use super::super::{Reader, tree};
/// The source byte under drawn text in the first paragraph.
fn under(source: &str, drawn: &str) -> usize {
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"));
let at = inline.text.find(drawn).expect("drawn");
inline.source_at(source, at).expect("a piece")
}
#[test]
fn a_word_lands_where_it_was_written() {
let source = "Say **bold** and `code` here\nnext line";
assert_eq!(under(source, "bold"), source.find("bold").unwrap());
assert_eq!(under(source, "code"), source.find("code").unwrap());
assert_eq!(under(source, "here"), source.find("here").unwrap());
assert_eq!(under(source, "next"), source.find("next").unwrap());
}
#[test]
fn joins_escapes_and_indents_read_back() {
let source = "a \\*star\\* x\n y";
assert_eq!(under(source, "x"), source.find('x').unwrap());
assert_eq!(under(source, "y"), source.find('y').unwrap());
}
#[test]
fn text_that_does_not_read_back_lands_at_its_start() {
let source = "a &amp; b";
assert_eq!(under(source, "b"), 0, "the reference ends the reading");
let source = "x :smile: y";
assert_eq!(under(source, "y"), 0);
}
}