documents: presses read back through quotes, code, HTML and front matter
Each drawn character finds its written one, skipping only white space, escapes and quote marks, so a press lands on the character itself. Code, front matter and link definitions read back line for line. An HTML block keeps each text run's source byte, and an alert keeps its text past the marker; alert() now takes the source. A secondary press refuses a target its write-back changed, and its focus no longer opens an empty document. Run's element stack moves to tree/stack.rs and the HTML-block reader to tree/html_block.rs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5af11fe489
commit
83d747d87e
16 files changed
+868
-480
No files matched your search
@@ -34,6 +34,9 @@ pub(super) fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<u
|
||||
impl MarkdownDocument {
|
||||
/// An empty, read visual document takes typing at once.
|
||||
pub(super) fn focused(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if std::mem::take(&mut self.pointing) {
|
||||
return;
|
||||
}
|
||||
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, Landing::End, window, cx);
|
||||
|
||||
@@ -102,6 +102,8 @@ pub struct MarkdownDocument {
|
||||
/// The block, text, typing and heights last centered.
|
||||
centered: Option<(usize, u64, u64, [u64; 2])>,
|
||||
focus: FocusHandle,
|
||||
/// A secondary press took focus.
|
||||
pointing: bool,
|
||||
on_link: Option<OnLink>,
|
||||
on_heading: Option<OnHeading>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
@@ -165,6 +167,7 @@ impl MarkdownDocument {
|
||||
centered: None,
|
||||
room: Pixels::ZERO,
|
||||
focus,
|
||||
pointing: false,
|
||||
on_link: None,
|
||||
on_heading: Some(on_heading),
|
||||
_subscriptions: subscriptions,
|
||||
|
||||
@@ -107,7 +107,8 @@ mod tests {
|
||||
]
|
||||
);
|
||||
let drawn = |ix: usize| found.blocks[ix].nodes.clone().expect("drawn");
|
||||
assert_eq!(drawn(0), [Node::FrontMatter("title: a".into())]);
|
||||
let text = "title: a".into();
|
||||
assert_eq!(drawn(0), [Node::FrontMatter { text, from: 4 }]);
|
||||
assert!(matches!(&drawn(3)[..], [Node::Paragraph(line)] if line.text == "x"));
|
||||
let headings: Vec<(u8, &str, usize)> = found
|
||||
.headings
|
||||
|
||||
@@ -6,6 +6,7 @@ use markdown::mdast;
|
||||
use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved};
|
||||
use crate::documents::{
|
||||
render::{OnPoint, Place, Pointed},
|
||||
tree::lines_at,
|
||||
tree::{alert, is_toc},
|
||||
};
|
||||
|
||||
@@ -35,7 +36,7 @@ impl TargetBlock {
|
||||
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(quote) if alert(quote, source).is_some() => Self::Alert,
|
||||
mdast::Node::Blockquote(_) => Self::Quote,
|
||||
mdast::Node::List(_) => Self::List,
|
||||
mdast::Node::Code(_) => Self::Code,
|
||||
@@ -73,6 +74,10 @@ impl MarkdownDocument {
|
||||
// The drawing counts from its block's start.
|
||||
let at = match &pointed.place {
|
||||
Place::Text(inline, drawn) => inline.source_at(&parsed.text[start..], *drawn),
|
||||
// Unreadable lines land at their start.
|
||||
Place::Lines { text, from, at } => {
|
||||
Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from))
|
||||
}
|
||||
Place::At(at) => Some(*at),
|
||||
Place::Block => None,
|
||||
}
|
||||
@@ -104,6 +109,7 @@ impl MarkdownDocument {
|
||||
block => block.flatten(),
|
||||
};
|
||||
let kind = block.map(|block| block.kind);
|
||||
let pressed = block.map(|block| self.parsed.text[block.range.clone()].to_string());
|
||||
let at = at.or(block.map(|block| block.range.start));
|
||||
let written = self.commit(cx);
|
||||
if written.is_some() {
|
||||
@@ -119,8 +125,17 @@ impl MarkdownDocument {
|
||||
None => return log::warn!("markdown document: the pressed text was written over"),
|
||||
},
|
||||
};
|
||||
// A write-back may change the pressed block.
|
||||
let now = (self.parsed.blocks.iter())
|
||||
.find(|block| block.range.start <= at && at <= block.range.end)
|
||||
.map(|block| self.parsed.text[block.range.clone()].to_string());
|
||||
if written.is_some() && pressed.is_some() && now != pressed {
|
||||
return log::warn!("markdown document: the write-back changed the pressed block");
|
||||
}
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
||||
// Its focus opens no empty block.
|
||||
self.pointing = !self.focus.is_focused(window);
|
||||
window.focus(&self.focus, cx);
|
||||
log::info!("markdown document: a secondary press puts the caret at byte {at}");
|
||||
cx.emit(DocumentEvent::Pointed(DocumentTarget {
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||
use gpui::{Entity, Modifiers, MouseButton, Pixels, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{open, type_in};
|
||||
use crate::{
|
||||
documents::{DocumentEvent, DocumentTarget, MarkdownDocument, MarkdownMode, TargetBlock},
|
||||
documents::{
|
||||
DocumentEvent, DocumentTarget, MarkdownDocument, MarkdownMode, TargetBlock,
|
||||
document::parse::parsed,
|
||||
tree::{Inline, Node},
|
||||
},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
|
||||
@@ -163,3 +167,164 @@ fn a_secondary_press_past_the_end_goes_to_the_end(cx: &mut TestAppContext) {
|
||||
assert_eq!(*heard.borrow(), [expected]);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
}
|
||||
|
||||
/// Base and small text sizes, in pixels.
|
||||
fn sizes(cx: &mut VisualTestContext) -> (Pixels, Pixels) {
|
||||
cx.update(|window, cx| {
|
||||
let size = |size| cx.theme().text_size(size).to_pixels(window.rem_size());
|
||||
(size(TextSize::Base), size(TextSize::Sm))
|
||||
})
|
||||
}
|
||||
|
||||
fn press_at(x: Pixels, y: Pixels, button: MouseButton, cx: &mut VisualTestContext) {
|
||||
cx.simulate_mouse_down(point(x, y), button, Modifiers::none());
|
||||
cx.simulate_mouse_up(point(x, y), button, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Presses block 0's code glyph `k` on `line`.
|
||||
fn press_lines(k: usize, line: usize, button: MouseButton, cx: &mut VisualTestContext) {
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, small) = sizes(cx);
|
||||
let x = bounds.left() + px(25.0) + small * 0.6 * (k as f32 + 0.25);
|
||||
let y = bounds.top() + px(17.0) + body * 1.6 * (line as f32 + 0.25);
|
||||
press_at(x, y, button, cx);
|
||||
}
|
||||
|
||||
/// The source byte under drawn `shown` in block 0.
|
||||
fn read_back(source: &str, shown: &str) -> Option<usize> {
|
||||
fn find<'a>(nodes: &'a [Node], shown: &str) -> Option<&'a Inline> {
|
||||
nodes.iter().find_map(|node| match node {
|
||||
Node::Paragraph(inline) | Node::Heading(_, inline) if inline.text.contains(shown) => {
|
||||
Some(inline)
|
||||
}
|
||||
Node::Quote(body) | Node::Alert { body, .. } | Node::Footnote { body, .. } => {
|
||||
find(body, shown)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
let parsed = parsed(source);
|
||||
let block = &parsed.blocks[0];
|
||||
let inline = find(block.nodes.as_ref().expect("drawn"), shown).expect("shown");
|
||||
let at = inline.text.find(shown).expect("shown");
|
||||
let written = inline.source_at(&source[block.range.start..], at)?;
|
||||
Some(block.range.start + written)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_marks_escapes_trims_and_alert_markers_read_back() {
|
||||
let cases = [
|
||||
("> alpha\n> beta", "eta"),
|
||||
("> [!NOTE]\n> alpha beta", "beta"),
|
||||
("a \\*star\\* z", "*star"),
|
||||
("a ` xy ` z", "xy"),
|
||||
("<div>alpha beta</div>\n", "eta"),
|
||||
("> <div>\n> alpha beta</div>\n", "beta"),
|
||||
];
|
||||
for (source, shown) in cases {
|
||||
assert_eq!(read_back(source, shown), source.find(shown), "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_in_a_quote_lands_past_its_mark(cx: &mut TestAppContext) {
|
||||
let source = "> alpha\n> beta";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, _) = sizes(cx);
|
||||
let x = bounds.left() + px(31.0) + body * 0.6 * 7.25;
|
||||
press_at(x, bounds.top() + px(10.0), MouseButton::Right, cx);
|
||||
let at = source.find("eta").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_in_an_alert_lands_past_its_marker(cx: &mut TestAppContext) {
|
||||
let source = "> [!NOTE]\n> alpha beta";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
let (body, _) = sizes(cx);
|
||||
let x = bounds.left() + px(31.0) + body * 0.6 * 6.25;
|
||||
let y = bounds.top() + px(15.0) + body * 1.6;
|
||||
press_at(x, y, MouseButton::Right, cx);
|
||||
let at = source.find("beta").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Alert)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_html_block_press_lands_on_its_character(cx: &mut TestAppContext) {
|
||||
let source = "<div>alpha beta</div>\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 7, MouseButton::Right, cx);
|
||||
let at = source.find("eta").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::Html)]);
|
||||
press_on(0, 7, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), at);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_definition_press_lands_on_its_character(cx: &mut TestAppContext) {
|
||||
let source = "[ref]: https://ely.dev\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_on(0, 10, MouseButton::Right, cx);
|
||||
assert_eq!(*events.borrow(), [target(10, TargetBlock::Definition)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn code_presses_land_line_for_line(cx: &mut TestAppContext) {
|
||||
for source in [" alpha\n beta\n", "```\r\nalpha\r\nbeta\r\n```\r\n"] {
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_lines(1, 1, MouseButton::Right, cx);
|
||||
let at = source.find("eta").unwrap();
|
||||
let expected = DocumentTarget {
|
||||
in_code: true,
|
||||
..target(at, TargetBlock::Code)
|
||||
};
|
||||
assert_eq!(*events.borrow(), [expected], "{source:?}");
|
||||
press_lines(1, 1, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), at, "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_front_matter_press_lands_past_its_fence(cx: &mut TestAppContext) {
|
||||
let source = "---\ntitle: value\n---\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
press_lines(7, 0, MouseButton::Right, cx);
|
||||
let at = source.find("value").unwrap();
|
||||
assert_eq!(*events.borrow(), [target(at, TargetBlock::FrontMatter)]);
|
||||
press_lines(7, 0, MouseButton::Left, cx);
|
||||
assert_eq!(opened_caret(&document, cx), at);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_secondary_press_on_an_empty_document_opens_nothing(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("", MarkdownMode::Visual, cx);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
let events = heard(&document, cx);
|
||||
press_at(px(25.0), px(25.0), MouseButton::Right, cx);
|
||||
assert_eq!(events.borrow().len(), 1);
|
||||
assert!(document.read_with(cx, |document, _| document.open.is_none()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_write_back_that_changes_the_pressed_block_points_nowhere(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("First.\n\nalpha beta\n", MarkdownMode::Visual, cx);
|
||||
let events = heard(&document, cx);
|
||||
type_in(&document, Some(0), Some("```"), cx);
|
||||
press_on(1, 7, MouseButton::Right, cx);
|
||||
let written = "```\n\nalpha beta\n";
|
||||
assert_eq!(super::text(&document, cx), written);
|
||||
assert!(
|
||||
events.borrow().is_empty(),
|
||||
"the pressed paragraph became code"
|
||||
);
|
||||
assert_ne!(caret(&document, cx), written.find("eta").unwrap());
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Context, ExternalPaths, FontWeight, InteractiveElement,
|
||||
IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render,
|
||||
StatefulInteractiveElement, Styled, Window, actions, div, list, prelude::FluentBuilder as _,
|
||||
StatefulInteractiveElement, Styled, StyledText, Window, actions, div, list,
|
||||
prelude::FluentBuilder as _,
|
||||
};
|
||||
|
||||
use super::{MarkdownDocument, edit::Landing};
|
||||
use crate::{
|
||||
documents::{
|
||||
MarkdownMode,
|
||||
render::{Hooks, Pointed, draw_nodes},
|
||||
render::{Hooks, Pointed, draw_nodes, point_lines},
|
||||
},
|
||||
forms::{self, Down, Up},
|
||||
primitives::FocusRing as _,
|
||||
@@ -113,11 +114,25 @@ impl MarkdownDocument {
|
||||
self.look.scale,
|
||||
cx,
|
||||
)),
|
||||
None => div()
|
||||
.text_size(body)
|
||||
.font_family(mono)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.parsed.text[block.range.clone()].to_string()),
|
||||
None => {
|
||||
let raw = &self.parsed.text[block.range.clone()];
|
||||
let drawn = StyledText::new(raw.to_string());
|
||||
let layout = drawn.layout().clone();
|
||||
let shown = div()
|
||||
.text_size(body)
|
||||
.font_family(mono)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(drawn)
|
||||
.into_any_element();
|
||||
div().child(point_lines(
|
||||
shown,
|
||||
layout,
|
||||
raw,
|
||||
0,
|
||||
&on_point,
|
||||
(false, false),
|
||||
))
|
||||
}
|
||||
};
|
||||
let block = div()
|
||||
.id(("block", ix))
|
||||
|
||||
Reference in new issue
Block a user