documents: a right press in an open block targets the pressed byte
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 23s
CI / crate (push) Failing after 11m46s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

The open block's target was read at the selection's start and from
the syntax tree only, so a press on the second of two selected links
named the first, and an inline anchor read as its outer link.
point_open now reads the byte under the press and resolves links as
drawing does; delete_image takes that byte.

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 20:18:22 -04:00
1 parent 6df9ab0aa6
commit 79e94f7e24
6 files changed
+165 -36

No files matched your search

+4 -3
View File
@@ -29,9 +29,10 @@ impl MarkdownDocument {
)
}
/// Removes the picture at the caret, read apart.
pub fn delete_image(&mut self, cx: &mut Context<Self>) -> Result<(), EditRefusal> {
self.apart("delete image".to_string(), drop_image, cx)
/// Removes the picture at source byte `at`, read apart.
pub fn delete_image(&mut self, at: usize, cx: &mut Context<Self>) -> Result<(), EditRefusal> {
let drop = move |full: &str, _| drop_image(full, at);
self.apart("delete image".to_string(), drop, cx)
}
/// `command` at the caret, read apart; refusals by event.
+1 -2
View File
@@ -62,11 +62,10 @@ pub enum EditRefusal {
pub(super) type Change = (Vec<(Range<usize>, String)>, Range<usize>);
/// Removes the picture at the caret, as Typora's Delete Image.
pub(super) fn drop_image(text: &str, selection: Range<usize>) -> Result<Change, EditRefusal> {
pub(super) fn drop_image(text: &str, at: usize) -> Result<Change, EditRefusal> {
let root = file_tree(text);
let mut nodes = Vec::new();
walk(&root, &mut nodes);
let at = selection.start;
let picture = (nodes.iter())
.filter(|node| matches!(node, Node::Image(_) | Node::ImageReference(_)))
.map(|node| span(node))
+89 -22
View File
@@ -1,13 +1,13 @@
use std::rc::Rc;
use gpui::{AppContext as _, Context, MouseButton, Window};
use gpui::{AppContext as _, Context, MouseButton, Pixels, Point, Window};
use markdown::mdast;
use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved, parse::Block};
use crate::documents::{
markdown::{file_tree, span, walk},
render::{OnPoint, Place, Pointed},
tree::{Node, alert, is_toc, lines_at},
tree::{Node, Reader, alert, is_toc, lines_at},
};
/// A top-level block's kind.
@@ -55,7 +55,7 @@ impl TargetBlock {
/// What a secondary press landed on.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DocumentTarget {
/// The caret's source byte; an open block's as unwritten.
/// The pressed source byte; an open block's as unwritten.
pub at: usize,
/// None past the last block.
pub block: Option<TargetBlock>,
@@ -162,16 +162,17 @@ impl MarkdownDocument {
cx.notify();
}
/// A right press in the open block; it stays open.
pub(super) fn point_open(&mut self, cx: &mut Context<Self>) {
if self.open.is_none() {
/// A right press at `position`; the open block stays open.
pub(super) fn point_open(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
let Some(open) = &self.open else {
return log::warn!("markdown document: a press on a block closed since");
}
};
let pressed = open.input.read(cx).offset_for_point(position);
let taken = self.snapshot(cx);
let (full, _, selection) = taken.clone();
let (full, offset, _) = taken.clone();
cx.spawn(async move |document, cx| {
let target = cx
.background_spawn(async move { target_at(&full, selection.start) })
.background_spawn(async move { target_at(&full, offset + pressed) })
.await;
document
.update(cx, |document, cx| {
@@ -187,18 +188,24 @@ impl MarkdownDocument {
}
}
/// What byte `at` of `text` holds.
/// What byte `at` of `text` holds, read as drawn.
fn target_at(text: &str, at: usize) -> DocumentTarget {
let root = file_tree(text);
let mut nodes = Vec::new();
walk(&root, &mut nodes);
let block = (root
let top = (root
.children()
.map(Vec::as_slice)
.unwrap_or_default()
.iter())
.find(|node| span(node).start <= at && at <= span(node).end)
.map(|node| TargetBlock::of(node, text));
.find(|node| span(node).start <= at && at <= span(node).end);
let block = top.map(|node| TargetBlock::of(node, text));
let mut drawn = Vec::new();
if let Some(node) = top.filter(|node| !matches!(node, mdast::Node::Definition(_))) {
Reader::new(text, &root).top(node, &mut drawn);
}
let mut flat = Vec::new();
flatten(&drawn, &mut flat);
let around: Vec<&mdast::Node> = (nodes.iter().copied())
.filter(|node| span(node).contains(&at))
.collect();
@@ -210,16 +217,37 @@ fn target_at(text: &str, at: usize) -> DocumentTarget {
_ => None,
})
};
let link = around.iter().rev().find_map(|node| match node {
mdast::Node::Link(link) => Some(link.url.clone()),
mdast::Node::LinkReference(link) => defined(&link.identifier),
_ => None,
});
let image = around.iter().rev().find_map(|node| match node {
mdast::Node::Image(picture) => Some(picture.url.clone()),
mdast::Node::ImageReference(picture) => defined(&picture.identifier),
_ => None,
let written = || {
around.iter().rev().find_map(|node| match node {
mdast::Node::Link(link) => Some(link.url.clone()),
mdast::Node::LinkReference(link) => defined(&link.identifier),
_ => None,
})
};
let picture = around
.iter()
.rev()
.find(|node| matches!(node, mdast::Node::Image(_) | mdast::Node::ImageReference(_)));
let shown = picture.and_then(|node| {
flat.iter().find_map(|kid| match kid {
Node::Image { at, url, link, .. } if *at == span(node).start => {
Some((url.clone(), link.clone()))
}
_ => None,
})
});
let image = match &shown {
Some((url, _)) => Some(url.clone()),
None => picture.and_then(|node| match node {
mdast::Node::Image(picture) => Some(picture.url.clone()),
mdast::Node::ImageReference(picture) => defined(&picture.identifier),
_ => None,
}),
};
let link = match shown {
Some((_, link)) => link,
None => drawn_link(&flat, text, at).unwrap_or_else(written),
};
let code = around.iter().find_map(|node| match node {
mdast::Node::Code(code) => Some(code.value.clone()),
_ => None,
@@ -235,3 +263,42 @@ fn target_at(text: &str, at: usize) -> DocumentTarget {
code,
}
}
/// `nodes` and all they hold, depth first.
fn flatten<'a>(nodes: &'a [Node], out: &mut Vec<&'a Node>) {
for node in nodes {
out.push(node);
match node {
Node::Quote(body) | Node::Footnote { body, .. } | Node::Alert { body, .. } => {
flatten(body, out)
}
Node::List { items, .. } => items.iter().for_each(|(_, body)| flatten(body, out)),
Node::Table { head, rows, .. } => {
(head.iter().chain(rows.iter().flatten())).for_each(|cell| flatten(cell, out))
}
_ => {}
}
}
}
/// The link drawn at source byte `at`; none if undrawn.
fn drawn_link(nodes: &[&Node], text: &str, at: usize) -> Option<Option<String>> {
let inline = nodes.iter().find_map(|node| match node {
Node::Paragraph(inline) | Node::Heading(_, inline)
if (inline.sources.iter()).any(|(_, span, _)| span.contains(&at)) =>
{
Some(inline)
}
_ => 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 link = (inline.links.iter()).find(|(range, _)| range.contains(&drawn));
Some(link.map(|(_, url)| url.clone()))
}
+68 -6
View File
@@ -5,7 +5,7 @@ use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, po
use super::{MarkdownMode, open, opened, point::heard, point::press_on, text};
use crate::documents::{DocumentTarget, EditRefusal, MarkdownDocument, TargetBlock};
/// A right press near the left of the open block's text.
/// A right press near the open block's left edge.
fn press_open(cx: &mut VisualTestContext) {
let field = cx.debug_bounds("markdown-open").expect("a block is open");
let at = point(field.left() + px(20.0), field.top() + px(14.0));
@@ -48,9 +48,11 @@ fn a_right_press_in_a_selection_keeps_the_block_and_the_selection(cx: &mut TestA
Some(0..22),
"kept open and selected"
);
let at = heard.borrow().first().expect("heard").at;
assert!(at < 6, "the pressed byte, not the selection's: {at}");
let target = DocumentTarget {
link: Some("https://x.test".into()),
..super::point::target(0, TargetBlock::Paragraph)
..super::point::target(at, TargetBlock::Paragraph)
};
assert_eq!(*heard.borrow(), [target]);
assert_eq!(text(&document, cx), LINKED, "nothing written");
@@ -109,14 +111,12 @@ fn delete_image_takes_the_picture_away_in_one_step(cx: &mut TestAppContext) {
let (document, cx) = open(source, MarkdownMode::Source, cx);
let heard = super::format::refusals(&document, cx);
document.update(cx, |document, cx| {
document.set_caret(0, 0, cx);
document.delete_image(cx).expect("taken");
document.delete_image(0, cx).expect("taken");
});
cx.run_until_parked();
assert_eq!(*heard.borrow(), [EditRefusal::NoImage]);
document.update(cx, |document, cx| {
document.set_caret(0, 4, cx);
document.delete_image(cx).expect("taken");
document.delete_image(4, cx).expect("taken");
});
cx.run_until_parked();
assert_eq!(text(&document, cx), "A B\n");
@@ -133,3 +133,65 @@ fn text_pastes_at_the_caret_with_no_block_open(cx: &mut TestAppContext) {
cx.simulate_keystrokes("secondary-v");
assert_eq!(text(&document, cx), "New First.\n");
}
/// A right press on byte `at` of the open block.
fn press_open_at(document: &Entity<MarkdownDocument>, at: usize, cx: &mut VisualTestContext) {
let spot = document.read_with(cx, |document, cx| {
let input = document.open.as_ref().expect("open").input.read(cx);
input.bounds_for_range(at..at + 1)[0].center()
});
cx.simulate_mouse_down(spot, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(spot, MouseButton::Right, Modifiers::none());
cx.run_until_parked();
}
const TWO: &str = "[one](https://a.test) [two](https://b.test)\n";
#[gpui::test]
fn a_press_in_a_selection_reads_the_pressed_link(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);
press_open_at(&document, TWO.find("two").expect("two") + 1, cx);
assert_eq!(open_selection(&document, cx), Some(0..43), "kept");
let link = heard
.borrow()
.first()
.and_then(|target| target.link.clone());
assert_eq!(link.as_deref(), Some("https://b.test"));
}
const PICTURES: &str = "x ![a](a.png) ![b](b.png)\n";
#[gpui::test]
fn delete_image_takes_the_pressed_picture(cx: &mut TestAppContext) {
let (document, cx) = open(PICTURES, MarkdownMode::Visual, cx);
let heard = heard(&document, cx);
press_on(0, 0, MouseButton::Left, cx);
select_open(&document, 0..25, cx);
press_open_at(&document, PICTURES.find("b.png").expect("b"), cx);
let target = heard.borrow().first().cloned().expect("heard");
assert_eq!(target.image.as_deref(), Some("b.png"));
document.update(cx, |document, cx| {
document.delete_image(target.at, cx).expect("taken");
});
cx.run_until_parked();
assert_eq!(opened(&document, cx), "x ![a](a.png) ");
}
const ANCHORED: &str = "[<a href=\"https://inner.test\">inside</a>](https://outer.test)\n";
#[gpui::test]
fn an_open_block_reads_an_anchor_as_drawn(cx: &mut TestAppContext) {
let (document, cx) = open(ANCHORED, MarkdownMode::Visual, cx);
let heard = heard(&document, cx);
press_on(0, 1, MouseButton::Left, cx);
select_open(&document, 0..0, cx);
press_open_at(&document, ANCHORED.find("inside").expect("word") + 2, cx);
let link = heard
.borrow()
.first()
.and_then(|target| target.link.clone());
assert_eq!(link.as_deref(), Some("https://inner.test"));
}
+2 -2
View File
@@ -72,10 +72,10 @@ impl MarkdownDocument {
// Focus fades all but the reader's block.
let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx);
let open = self.open.as_ref();
let point = cx.listener(|document, _: &MouseDownEvent, window, cx| {
let point = cx.listener(|document, event: &MouseDownEvent, window, cx| {
window.prevent_default();
cx.stop_propagation();
document.point_open(cx);
document.point_open(event.position, cx);
});
let field = |open: &super::Open| {
div()