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

+27 -6
View File
@@ -12,8 +12,17 @@ use crate::{
forms::{InputEvent, TextInput},
};
/// Where an opened block's caret lands.
#[derive(Clone, Copy, Debug)]
pub(super) enum Landing {
Start,
End,
/// A source byte of the drawn read.
At(usize),
}
/// Where byte `at` lies after `edit`, if it survives.
fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
pub(super) fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
match edit {
None => Some(at),
Some((replaced, _)) if at < replaced.start => Some(at),
@@ -27,7 +36,7 @@ impl MarkdownDocument {
pub(super) fn focused(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let empty = self.editor.read(cx).text().trim().is_empty() && self.read_now(cx);
if self.mode == MarkdownMode::Visual && self.open.is_none() && empty {
self.open(None, false, window, cx);
self.open(None, Landing::End, window, cx);
}
}
@@ -47,7 +56,7 @@ impl MarkdownDocument {
pub(super) fn open(
&mut self,
ix: Option<usize>,
at_start: bool,
landing: Landing,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -78,6 +87,17 @@ impl MarkdownDocument {
(block.range.clone(), false)
}
};
let caret = match landing {
Landing::Start => Some(0),
Landing::End => None,
Landing::At(at) => match moved(at, &written) {
Some(at) if range.contains(&at) || at == range.end => Some(at - range.start),
_ => {
log::warn!("markdown document: the pressed byte left its block");
None
}
},
};
let source = text[range.clone()].to_string();
let input = cx.new(|cx| {
TextInput::new(window, cx)
@@ -86,8 +106,8 @@ impl MarkdownDocument {
});
input.update(cx, |input, cx| {
input.set_text(source, cx);
if at_start {
input.select(0..0, cx);
if let Some(caret) = caret {
input.select(caret..caret, cx);
}
});
window.focus(&input.focus_handle(cx), cx);
@@ -166,7 +186,8 @@ impl MarkdownDocument {
let Some(next) = next else {
return false;
};
self.open(Some(next), down, window, cx);
let landing = if down { Landing::Start } else { Landing::End };
self.open(Some(next), landing, window, cx);
true
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ use std::ops::Range;
use gpui::{Context, Window};
use super::MarkdownDocument;
use super::{MarkdownDocument, edit::Landing};
use crate::documents::MarkdownMode;
impl MarkdownDocument {
@@ -42,7 +42,7 @@ impl MarkdownDocument {
return self.reveal(line(self, cx), window, cx);
};
let start = block.start;
self.open(Some(ix), false, window, cx);
self.open(Some(ix), Landing::End, window, cx);
self.list.scroll_to_reveal_item(ix);
if let Some(open) = &self.open {
let input = open.input.clone();
+5 -1
View File
@@ -5,6 +5,7 @@ mod lists;
mod look;
mod parse;
mod place;
mod point;
mod tables;
#[cfg(all(test, feature = "test-support"))]
mod tests;
@@ -23,6 +24,7 @@ pub use crate::documents::tree::DocumentHeading;
pub use attach::Attachment;
pub use look::DocumentLook;
use parse::{Parsed, alike, parsed};
pub use point::{DocumentTarget, TargetBlock};
pub use tables::{TableEdit, TableRefusal};
use super::{
@@ -46,12 +48,14 @@ pub const LARGEST_READ: usize = 2 << 20;
const PAUSE: Duration = Duration::from_millis(300);
/// What the document tells its owner.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, 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,
/// A secondary press moved the caret onto this.
Pointed(DocumentTarget),
}
/// A top line asked, numbered so late callbacks know theirs.
+30
View File
@@ -7,10 +7,13 @@ use crate::documents::{
tree::{DocumentHeading, Node, Reader},
};
use super::TargetBlock;
/// A top-level block: its source, and what it draws.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Block {
pub range: Range<usize>,
pub kind: TargetBlock,
/// None shows a link definition as written.
pub nodes: Option<Vec<Node>>,
}
@@ -39,10 +42,12 @@ pub(crate) fn parsed(text: &str) -> Parsed {
let nodes = (!raw).then(|| {
let mut out = Vec::new();
reader.top(node, &mut out);
out.iter_mut().for_each(|drawn| drawn.rebase(range.start));
out
});
Block {
range: range.start..range.start + kept,
kind: TargetBlock::of(node, text),
nodes,
}
})
@@ -149,6 +154,31 @@ mod tests {
assert_eq!(alike(&old, &shorter), (1, 0));
}
#[test]
fn pictures_and_code_count_from_their_block() {
let old = parsed(
"a
![p](p.png)
```
x
```
",
);
let new = parsed(
"a and more
![p](p.png)
```
x
```
",
);
assert_eq!(alike(&old, &new), (0, 2));
}
#[test]
fn a_reference_resolves_across_blocks() {
let found = parsed("[site]\n\n[site]: https://ely.dev\n");
+136
View File
@@ -0,0 +1,136 @@
use std::rc::Rc;
use gpui::{Context, MouseButton, Window};
use markdown::mdast;
use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved};
use crate::documents::{
render::{OnPoint, Place, Pointed},
tree::{alert, is_toc},
};
/// A top-level block's kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TargetBlock {
Paragraph,
Heading(u8),
Quote,
Alert,
List,
Code,
Math,
Table,
Rule,
Html,
Footnote,
Definition,
FrontMatter,
Toc,
}
impl TargetBlock {
/// `node`'s kind, read from `source`.
pub(super) fn of(node: &mdast::Node, source: &str) -> Self {
match node {
mdast::Node::Paragraph(_) if is_toc(node, source) => Self::Toc,
mdast::Node::Paragraph(_) => Self::Paragraph,
mdast::Node::Heading(heading) => Self::Heading(heading.depth),
mdast::Node::Blockquote(quote) if alert(quote).is_some() => Self::Alert,
mdast::Node::Blockquote(_) => Self::Quote,
mdast::Node::List(_) => Self::List,
mdast::Node::Code(_) => Self::Code,
mdast::Node::Math(_) => Self::Math,
mdast::Node::Table(_) => Self::Table,
mdast::Node::ThematicBreak(_) => Self::Rule,
mdast::Node::Html(_) => Self::Html,
mdast::Node::FootnoteDefinition(_) => Self::Footnote,
mdast::Node::Definition(_) => Self::Definition,
mdast::Node::Yaml(_) | mdast::Node::Toml(_) => Self::FrontMatter,
other => unreachable!("{other:?} is no block outside MDX"),
}
}
}
/// What a secondary press landed on.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DocumentTarget {
/// The source byte the caret went to.
pub at: usize,
/// None past the last block.
pub block: Option<TargetBlock>,
pub link: Option<String>,
pub image: Option<String>,
pub in_table: bool,
pub in_code: bool,
}
impl MarkdownDocument {
/// Presses on block `ix`'s drawing, read as it was drawn.
pub(super) fn on_point(&self, ix: usize, cx: &mut Context<Self>) -> OnPoint {
let (document, parsed) = (cx.entity().downgrade(), self.parsed.clone());
let start = parsed.blocks[ix].range.start;
Rc::new(move |pointed, button, window, cx| {
// The drawing counts from its block's start.
let at = match &pointed.place {
Place::Text(inline, drawn) => inline.source_at(&parsed.text[start..], *drawn),
Place::At(at) => Some(*at),
Place::Block => None,
}
.map(|at| start + at);
let ix = Some(ix);
document
.update(cx, |document, cx| match button {
MouseButton::Left => {
document.open(ix, at.map_or(Landing::End, Landing::At), window, cx)
}
MouseButton::Right => document.point(ix, at, pointed, window, cx),
_ => {}
})
.expect("a block's drawing lives in its document");
})
}
/// Caret to `at`, else block `ix`; the owner hears.
pub(super) fn point(
&mut self,
ix: Option<usize>,
at: Option<usize>,
pointed: Pointed,
window: &mut Window,
cx: &mut Context<Self>,
) {
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(),
};
let kind = block.map(|block| block.kind);
let at = at.or(block.map(|block| block.range.start));
let written = self.commit(cx);
if written.is_some() {
self.reparse(true, cx);
}
if !self.read_now(cx) {
return log::warn!("markdown document: pressed while the text is read again");
}
let at = match at {
None => self.editor.read(cx).text().len(),
Some(at) => match moved(at, &written) {
Some(at) => at,
None => return log::warn!("markdown document: the pressed text was written over"),
},
};
self.editor
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
window.focus(&self.focus, cx);
log::info!("markdown document: a secondary press puts the caret at byte {at}");
cx.emit(DocumentEvent::Pointed(DocumentTarget {
at,
block: kind,
link: pointed.link,
image: pointed.image,
in_table: pointed.table || kind == Some(TargetBlock::Table),
in_code: pointed.code || kind == Some(TargetBlock::Code),
}));
cx.notify();
}
}
+7 -6
View File
@@ -2,6 +2,7 @@ use gpui::{TestAppContext, px, rems};
use super::{MarkdownMode, frames, open};
use crate::documents::DocumentLook;
use crate::documents::document::edit::Landing;
/// Sixty short paragraphs, each its own block.
fn long() -> String {
@@ -30,7 +31,7 @@ fn typewriter_keeps_the_open_block_mid_view(cx: &mut TestAppContext) {
};
document.update_in(cx, |document, window, cx| {
document.set_look(look, cx);
document.open(Some(40), false, window, cx);
document.open(Some(40), Landing::End, window, cx);
document.list.scroll_to_reveal_item(40);
});
frames(cx);
@@ -99,7 +100,7 @@ fn the_scale_sizes_source_lines(cx: &mut TestAppContext) {
fn focus_counts_the_open_block_as_the_readers(cx: &mut TestAppContext) {
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
document.update_in(cx, |document, window, cx| {
document.open(Some(3), false, window, cx)
document.open(Some(3), Landing::End, window, cx)
});
assert_eq!(
document.read_with(cx, |document, cx| document.current_block(cx)),
@@ -137,7 +138,7 @@ fn off_middle(
fn an_open_block_takes_the_scale(cx: &mut TestAppContext) {
let (document, cx) = open("A paragraph.\n", MarkdownMode::Visual, cx);
document.update_in(cx, |document, window, cx| {
document.open(Some(0), false, window, cx)
document.open(Some(0), Landing::End, window, cx)
});
frames(cx);
let plain = document
@@ -163,7 +164,7 @@ fn a_resize_centers_the_block_again(cx: &mut TestAppContext) {
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
document.update_in(cx, |document, window, cx| {
document.set_look(typewriter(), cx);
document.open(Some(40), false, window, cx);
document.open(Some(40), Landing::End, window, cx);
document.list.scroll_to_reveal_item(40);
});
frames(cx);
@@ -177,13 +178,13 @@ fn the_first_and_last_blocks_center(cx: &mut TestAppContext) {
let (document, cx) = open(&long(), MarkdownMode::Visual, cx);
document.update_in(cx, |document, window, cx| {
document.set_look(typewriter(), cx);
document.open(Some(0), false, window, cx);
document.open(Some(0), Landing::End, window, cx);
});
frames(cx);
let first = off_middle(&document, 0, cx);
assert!(first < px(4.0), "the first is {first:?} off");
document.update_in(cx, |document, window, cx| {
document.open(Some(59), false, window, cx);
document.open(Some(59), Landing::End, window, cx);
document.list.scroll_to_reveal_item(59);
});
frames(cx);
+5 -4
View File
@@ -2,7 +2,7 @@ use std::{cell::RefCell, rc::Rc};
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
use super::{AT_ONCE, DocumentEvent, MarkdownDocument, visual};
use super::{AT_ONCE, DocumentEvent, MarkdownDocument, edit::Landing, visual};
use crate::documents::MarkdownMode;
const TEXT: &str = "# Lift\n\nBlend toward white.\n\n- one\n- two\n";
@@ -236,7 +236,7 @@ pub(super) fn type_in(
cx: &mut VisualTestContext,
) {
document.update_in(cx, |document, window, cx| {
document.open(ix, false, window, cx);
document.open(ix, Landing::End, window, cx);
if let (Some(typed), Some(open)) = (typed, &document.open) {
open.input.update(cx, |input, cx| input.set_text(typed, cx));
}
@@ -353,11 +353,11 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) {
let long = format!("A\n\nB\n\nCCCC\n\n{}", "Prose.\n\n".repeat(AT_ONCE / 8 + 1));
let (document, cx) = open(&long, MarkdownMode::Visual, cx);
document.update_in(cx, |document, window, cx| {
document.open(Some(0), false, window, cx);
document.open(Some(0), Landing::End, window, cx);
let input = document.open.as_ref().expect("open").input.clone();
input.update(cx, |input, cx| input.set_text("AAAA", cx));
// B is pressed before the new read lands.
document.open(Some(1), false, window, cx);
document.open(Some(1), Landing::End, window, cx);
assert!(document.open.is_none(), "a stale range opens nothing");
});
cx.run_until_parked();
@@ -370,6 +370,7 @@ mod history;
mod links;
mod look;
mod place;
mod point;
mod toc;
mod tools;
+165
View File
@@ -0,0 +1,165 @@
//! A press lands on the pressed character (R4.5).
use std::{cell::RefCell, rc::Rc};
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
use super::{open, type_in};
use crate::{
documents::{DocumentEvent, DocumentTarget, MarkdownDocument, MarkdownMode, TargetBlock},
theme::{ActiveTheme, TextSize},
};
/// Presses block `ix` on its first line's character `k`.
fn press_on(ix: usize, k: usize, button: MouseButton, cx: &mut VisualTestContext) {
let bounds = cx
.debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str()))
.expect("the block is drawn");
// The test text system sets glyphs 0.6 em apart.
let size = cx.update(|window, cx| {
cx.theme()
.text_size(TextSize::Base)
.to_pixels(window.rem_size())
});
let em = size * 0.6;
let x = bounds.left() + px(13.0) + em * (k as f32 + 0.25);
let at = point(x, bounds.top() + px(10.0));
cx.simulate_mouse_down(at, button, Modifiers::none());
cx.simulate_mouse_up(at, button, Modifiers::none());
cx.run_until_parked();
}
fn heard(
document: &Entity<MarkdownDocument>,
cx: &mut VisualTestContext,
) -> Rc<RefCell<Vec<DocumentTarget>>> {
let heard = Rc::new(RefCell::new(Vec::new()));
let kept = heard.clone();
cx.update(|_, cx| {
cx.subscribe(document, move |_, event: &DocumentEvent, _| {
if let DocumentEvent::Pointed(target) = event {
kept.borrow_mut().push(target.clone());
}
})
.detach()
});
heard
}
fn caret(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> usize {
document.read_with(cx, |document, cx| document.editor.read(cx).primary().head)
}
fn opened_caret(document: &Entity<MarkdownDocument>, cx: &mut VisualTestContext) -> usize {
document.read_with(cx, |document, cx| {
document
.open
.as_ref()
.expect("open")
.input
.read(cx)
.cursor()
})
}
fn target(at: usize, block: TargetBlock) -> DocumentTarget {
DocumentTarget {
at,
block: Some(block),
link: None,
image: None,
in_table: false,
in_code: false,
}
}
const TEXT: &str = "First.\n\nSay **bold** and more.\n";
#[gpui::test]
fn a_press_opens_the_block_at_the_pressed_character(cx: &mut TestAppContext) {
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
press_on(1, 4, MouseButton::Left, cx);
assert_eq!(
opened_caret(&document, cx),
"Say **".len(),
"on the b of bold"
);
}
#[gpui::test]
fn a_secondary_press_moves_the_caret_and_opens_nothing(cx: &mut TestAppContext) {
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
let heard = heard(&document, cx);
press_on(1, 4, MouseButton::Right, cx);
assert!(document.read_with(cx, |document, _| document.open.is_none()));
let at = TEXT.find("bold").unwrap();
assert_eq!(caret(&document, cx), at);
assert_eq!(*heard.borrow(), [target(at, TargetBlock::Paragraph)]);
}
#[gpui::test]
fn a_secondary_press_on_a_link_names_its_address(cx: &mut TestAppContext) {
let text = "Go [there](https://ely.dev) now\n";
let (document, cx) = open(text, MarkdownMode::Visual, cx);
let heard = heard(&document, cx);
press_on(0, 4, MouseButton::Right, cx);
let expected = DocumentTarget {
link: Some("https://ely.dev".into()),
..target(text.find("here").unwrap(), TargetBlock::Paragraph)
};
assert_eq!(*heard.borrow(), [expected]);
}
#[gpui::test]
fn a_code_press_lands_in_its_code(cx: &mut TestAppContext) {
let text = "```rust\nlet x = 1;\n```\n";
let (document, cx) = open(text, MarkdownMode::Visual, cx);
let heard = heard(&document, cx);
press_on(0, 0, MouseButton::Right, cx);
let expected = DocumentTarget {
in_code: true,
..target("```rust\n".len(), TargetBlock::Code)
};
assert_eq!(*heard.borrow(), [expected]);
}
#[gpui::test]
fn a_secondary_press_on_a_picture_names_it(cx: &mut TestAppContext) {
let text = "Lead.\n\n![pic](missing.png)\n";
let (document, cx) = open(text, MarkdownMode::Visual, cx);
let heard = heard(&document, cx);
press_on(1, 1, MouseButton::Right, cx);
let expected = DocumentTarget {
image: Some("missing.png".into()),
..target(text.find("![").unwrap(), TargetBlock::Paragraph)
};
assert_eq!(*heard.borrow(), [expected]);
}
#[gpui::test]
fn a_secondary_press_writes_the_open_block_first(cx: &mut TestAppContext) {
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
let heard = heard(&document, cx);
type_in(&document, Some(0), Some("First, longer."), cx);
press_on(1, 4, MouseButton::Right, cx);
let written = "First, longer.\n\nSay **bold** and more.\n";
assert_eq!(super::text(&document, cx), written);
let at = written.find("bold").unwrap();
assert_eq!(caret(&document, cx), at, "the byte moved with the write");
assert_eq!(*heard.borrow(), [target(at, TargetBlock::Paragraph)]);
}
#[gpui::test]
fn a_secondary_press_past_the_end_goes_to_the_end(cx: &mut TestAppContext) {
let (document, cx) = open(TEXT, MarkdownMode::Visual, cx);
let heard = heard(&document, cx);
let end = cx.debug_bounds("markdown-block-1").expect("drawn").bottom() + px(12.0);
cx.simulate_mouse_down(point(px(40.0), end), MouseButton::Right, Modifiers::none());
cx.run_until_parked();
let expected = DocumentTarget {
block: None,
..target(TEXT.len(), TargetBlock::Paragraph)
};
assert_eq!(*heard.borrow(), [expected]);
assert!(document.read_with(cx, |document, _| document.open.is_none()));
}
+25 -9
View File
@@ -4,9 +4,12 @@ use gpui::{
StatefulInteractiveElement, Styled, Window, actions, div, list, prelude::FluentBuilder as _,
};
use super::MarkdownDocument;
use super::{MarkdownDocument, edit::Landing};
use crate::{
documents::{MarkdownMode, render::draw_nodes},
documents::{
MarkdownMode,
render::{Hooks, Pointed, draw_nodes},
},
forms::{self, Down, Up},
primitives::FocusRing as _,
theme::{ActiveTheme, Radius, TextSize},
@@ -55,6 +58,9 @@ impl MarkdownDocument {
/// A block, or the end.
fn block_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let editing = self.mode == MarkdownMode::Visual;
let drawn = editing && ix < self.parsed.blocks.len();
let on_point = drawn.then(|| self.on_point(ix, cx));
let theme = cx.theme();
let colors = theme.colors.clone();
let (radius, body, mono) = (
@@ -64,7 +70,6 @@ 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 editing = self.mode == MarkdownMode::Visual;
let open = self.open.as_ref();
let field = |open: &super::Open| {
div()
@@ -99,9 +104,12 @@ impl MarkdownDocument {
.children(draw_nodes(
&("render", ix).into(),
nodes,
&self.base,
&self.on_link,
&self.on_heading,
Hooks {
base: &self.base,
on_link: &self.on_link,
on_heading: &self.on_heading,
on_point: &on_point,
},
self.look.scale,
cx,
)),
@@ -125,7 +133,7 @@ impl MarkdownDocument {
}
}
/// `element` opens block `ix` on a press, Enter or Space.
/// `element` opens block `ix`; a right press points.
fn pressable(
&self,
element: gpui::Stateful<gpui::Div>,
@@ -149,13 +157,21 @@ impl MarkdownDocument {
}
window.prevent_default();
cx.stop_propagation();
document.open(ix, false, window, cx);
document.open(ix, Landing::End, window, cx);
}),
)
.on_mouse_down(
MouseButton::Right,
cx.listener(move |document, _: &MouseDownEvent, window, cx| {
window.prevent_default();
cx.stop_propagation();
document.point(ix, None, Pointed::block(), window, cx);
}),
)
.on_click(
cx.listener(move |document, event: &ClickEvent, window, cx| {
if event.is_keyboard() {
document.open(ix, false, window, cx);
document.open(ix, Landing::End, window, cx);
}
}),
)