documents: right presses keep a selection; Delete Image
A right press in a text field or the code editor keeps a selection it lands in, else moves the caret. In visual mode a right press in the open block keeps it open and emits Pointed, read apart from the whole text. MarkdownDocument::delete_image removes the picture at the caret. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6c2d28c56e
commit
013f14216f
9 files changed
+284
-5
No files matched your search
@@ -7,7 +7,7 @@ use gpui::{AppContext as _, Context};
|
||||
use super::{
|
||||
DocumentEvent, LARGEST_READ, MarkdownDocument,
|
||||
block::{self, BlockCommand, settled},
|
||||
format::{Change, EditRefusal, InlineFormat, toggle},
|
||||
format::{Change, EditRefusal, InlineFormat, drop_image, toggle},
|
||||
tables::{self, TableEdit},
|
||||
};
|
||||
use crate::documents::{MarkdownMode, modes::joint};
|
||||
@@ -29,6 +29,11 @@ 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)
|
||||
}
|
||||
|
||||
/// `command` at the caret, read apart; refusals by event.
|
||||
pub fn block(
|
||||
&mut self,
|
||||
@@ -107,7 +112,7 @@ impl MarkdownDocument {
|
||||
}
|
||||
|
||||
/// The text an edit reads: the open block's in place.
|
||||
fn snapshot(&self, cx: &Context<Self>) -> Snapshot {
|
||||
pub(super) fn snapshot(&self, cx: &Context<Self>) -> Snapshot {
|
||||
let text = self.editor.read(cx).text();
|
||||
let Some(open) = &self.open else {
|
||||
let picked = self.editor.read(cx).primary().range();
|
||||
|
||||
@@ -40,6 +40,8 @@ pub enum EditRefusal {
|
||||
Outside,
|
||||
/// A table holds the caret.
|
||||
Table,
|
||||
/// No picture holds the caret.
|
||||
NoImage,
|
||||
/// Front matter is there already.
|
||||
Present,
|
||||
/// The selection crosses a block or another mark.
|
||||
@@ -59,6 +61,23 @@ pub enum EditRefusal {
|
||||
/// The edits and the selection after them.
|
||||
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> {
|
||||
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))
|
||||
.find(|range| range.contains(&at))
|
||||
.ok_or(EditRefusal::NoImage)?;
|
||||
Ok((
|
||||
vec![(picture.clone(), String::new())],
|
||||
picture.start..picture.start,
|
||||
))
|
||||
}
|
||||
|
||||
/// Typora's word stops: punctuation and space.
|
||||
fn stop(ch: char) -> bool {
|
||||
ch.is_whitespace() || "()[]^&_-+*#@!~`\\/{}'\":;<>,.?%".contains(ch)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{Context, MouseButton, Window};
|
||||
use gpui::{AppContext as _, Context, MouseButton, 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},
|
||||
};
|
||||
@@ -54,7 +55,7 @@ impl TargetBlock {
|
||||
/// What a secondary press landed on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DocumentTarget {
|
||||
/// The source byte the caret went to.
|
||||
/// The caret's source byte; an open block's as unwritten.
|
||||
pub at: usize,
|
||||
/// None past the last block.
|
||||
pub block: Option<TargetBlock>,
|
||||
@@ -160,4 +161,77 @@ 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() {
|
||||
return log::warn!("markdown document: a press on a block closed since");
|
||||
}
|
||||
let taken = self.snapshot(cx);
|
||||
let (full, _, selection) = taken.clone();
|
||||
cx.spawn(async move |document, cx| {
|
||||
let target = cx
|
||||
.background_spawn(async move { target_at(&full, selection.start) })
|
||||
.await;
|
||||
document
|
||||
.update(cx, |document, cx| {
|
||||
if document.open.is_none() || document.snapshot(cx) != taken {
|
||||
return log::info!("markdown document: the pressed block changed first");
|
||||
}
|
||||
log::info!("markdown document: a secondary press in the open block");
|
||||
cx.emit(DocumentEvent::Pointed(target));
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// What byte `at` of `text` holds.
|
||||
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
|
||||
.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));
|
||||
let around: Vec<&mdast::Node> = (nodes.iter().copied())
|
||||
.filter(|node| span(node).contains(&at))
|
||||
.collect();
|
||||
let defined = |identifier: &str| {
|
||||
nodes.iter().find_map(|node| match node {
|
||||
mdast::Node::Definition(definition) if definition.identifier == identifier => {
|
||||
Some(definition.url.clone())
|
||||
}
|
||||
_ => 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 code = around.iter().find_map(|node| match node {
|
||||
mdast::Node::Code(code) => Some(code.value.clone()),
|
||||
_ => None,
|
||||
});
|
||||
DocumentTarget {
|
||||
at,
|
||||
block,
|
||||
link,
|
||||
image,
|
||||
in_table: around
|
||||
.iter()
|
||||
.any(|node| matches!(node, mdast::Node::Table(_))),
|
||||
code,
|
||||
}
|
||||
}
|
||||
@@ -377,6 +377,7 @@ mod place;
|
||||
mod point;
|
||||
mod presses;
|
||||
mod reading;
|
||||
mod secondary;
|
||||
mod toc;
|
||||
mod tools;
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Right presses that keep a selection; Delete Image (R4.10).
|
||||
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
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.
|
||||
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));
|
||||
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn open_selection(
|
||||
document: &Entity<MarkdownDocument>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Option<std::ops::Range<usize>> {
|
||||
document.read_with(cx, |document, cx| {
|
||||
(document.open.as_ref()).map(|open| open.input.read(cx).selection())
|
||||
})
|
||||
}
|
||||
|
||||
fn select_open(
|
||||
document: &Entity<MarkdownDocument>,
|
||||
range: std::ops::Range<usize>,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
document.update(cx, |document, cx| {
|
||||
let input = document.open.as_ref().expect("open").input.clone();
|
||||
input.update(cx, |input, cx| input.select(range, cx));
|
||||
});
|
||||
}
|
||||
|
||||
const LINKED: &str = "[docs](https://x.test)\n\nNext.\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn a_right_press_in_a_selection_keeps_the_block_and_the_selection(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(LINKED, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(0, 1, MouseButton::Left, cx);
|
||||
select_open(&document, 0..22, cx);
|
||||
press_open(cx);
|
||||
assert_eq!(
|
||||
open_selection(&document, cx),
|
||||
Some(0..22),
|
||||
"kept open and selected"
|
||||
);
|
||||
let target = DocumentTarget {
|
||||
link: Some("https://x.test".into()),
|
||||
..super::point::target(0, TargetBlock::Paragraph)
|
||||
};
|
||||
assert_eq!(*heard.borrow(), [target]);
|
||||
assert_eq!(text(&document, cx), LINKED, "nothing written");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_right_press_outside_the_selection_moves_the_caret(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open(LINKED, MarkdownMode::Visual, cx);
|
||||
press_on(0, 1, MouseButton::Left, cx);
|
||||
select_open(&document, 22..22, cx);
|
||||
press_open(cx);
|
||||
let at = open_selection(&document, cx).expect("still open");
|
||||
assert!(
|
||||
at.is_empty() && at.start < 5,
|
||||
"the caret went to the press: {at:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_reference_in_the_open_block_reads_its_definition_outside(cx: &mut TestAppContext) {
|
||||
let source = "[docs][d]\n\n[d]: https://x.test\n";
|
||||
let (document, cx) = open(source, MarkdownMode::Visual, cx);
|
||||
let heard = heard(&document, cx);
|
||||
press_on(0, 1, MouseButton::Left, cx);
|
||||
select_open(&document, 0..9, cx);
|
||||
press_open(cx);
|
||||
let link = heard
|
||||
.borrow()
|
||||
.first()
|
||||
.and_then(|target| target.link.clone());
|
||||
assert_eq!(link.as_deref(), Some("https://x.test"));
|
||||
assert_eq!(opened(&document, cx), "[docs][d]");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_right_press_in_source_keeps_the_selection(cx: &mut TestAppContext) {
|
||||
let (document, cx) = open("alpha beta\n", MarkdownMode::Source, cx);
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.select(std::iter::once(0..10), cx));
|
||||
let line = cx.debug_bounds("editor-line-0").expect("drawn");
|
||||
let at = point(line.left() + px(60.0), line.top() + px(6.0));
|
||||
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
let kept = editor.read_with(cx, |editor, _| editor.primary().range());
|
||||
assert_eq!(kept, 0..10);
|
||||
editor.update(cx, |editor, cx| editor.select(std::iter::once(10..10), cx));
|
||||
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
let moved = editor.read_with(cx, |editor, _| editor.primary().range());
|
||||
assert!(moved.is_empty() && moved.start < 10, "{moved:?}");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn delete_image_takes_the_picture_away_in_one_step(cx: &mut TestAppContext) {
|
||||
let source = "A  B\n";
|
||||
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");
|
||||
});
|
||||
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");
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(text(&document, cx), "A B\n");
|
||||
let editor = document.read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.undo(cx));
|
||||
assert_eq!(text(&document, cx), source);
|
||||
}
|
||||
@@ -72,8 +72,15 @@ 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| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
document.point_open(cx);
|
||||
});
|
||||
let field = |open: &super::Open| {
|
||||
div()
|
||||
.debug_selector(|| "markdown-open".to_string())
|
||||
.on_mouse_down(MouseButton::Right, point)
|
||||
.text_size(body)
|
||||
.px_3()
|
||||
.py_2()
|
||||
|
||||
@@ -335,6 +335,12 @@ impl CodeEditor {
|
||||
editor.press(line, event, window, cx)
|
||||
}),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |editor, event: &MouseDownEvent, window, cx| {
|
||||
editor.secondary_press(line, event, window, cx)
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(cx.listener(move |editor, event: &MouseMoveEvent, _, cx| {
|
||||
editor.drag(line, event, cx)
|
||||
}));
|
||||
@@ -392,6 +398,27 @@ impl CodeEditor {
|
||||
self.set_selections(chosen, cx);
|
||||
}
|
||||
|
||||
/// A right press keeps a selection it lands in.
|
||||
pub(crate) fn secondary_press(
|
||||
&mut self,
|
||||
line: usize,
|
||||
event: &MouseDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
window.focus(&self.focus, cx);
|
||||
let Some(at) = self.offset_in(line, event.position.x, cx) else {
|
||||
return;
|
||||
};
|
||||
let held = (self.selections.iter()).any(|selection| {
|
||||
let range = selection.range();
|
||||
!range.is_empty() && (range.start..=range.end).contains(&at)
|
||||
});
|
||||
if !held {
|
||||
self.set_selections(vec![Selection::caret(at)], cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag over a line stretches the primary selection to where the pointer is.
|
||||
pub(crate) fn drag(&mut self, line: usize, event: &MouseMoveEvent, cx: &mut Context<Self>) {
|
||||
if !self.dragging || !event.dragging() {
|
||||
|
||||
@@ -343,6 +343,25 @@ impl TextInput {
|
||||
}
|
||||
}
|
||||
|
||||
/// A right press keeps a selection it lands in.
|
||||
fn secondary_down(
|
||||
&mut self,
|
||||
event: &MouseDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.disabled {
|
||||
return;
|
||||
}
|
||||
window.focus(&self.focus, cx);
|
||||
let at = self.offset_for_point(event.position);
|
||||
let held =
|
||||
!self.selection.is_empty() && (self.selection.start..=self.selection.end).contains(&at);
|
||||
if !held {
|
||||
self.move_to(at, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selecting && event.dragging() {
|
||||
let at = self.offset_for_point(event.position);
|
||||
@@ -405,6 +424,7 @@ impl Render for TextInput {
|
||||
.on_action(cx.listener(Self::submit))
|
||||
.on_action(cx.listener(Self::show_character_palette))
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
|
||||
.on_mouse_down(MouseButton::Right, cx.listener(Self::secondary_down))
|
||||
.on_mouse_move(cx.listener(Self::mouse_move))
|
||||
.on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up))
|
||||
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up))
|
||||
|
||||
Reference in new issue
Block a user