Merge branch 'main' into fix/git-drawer-repaint-20260920
This commit is contained in:
20 files changed
+1868
-62
No files matched your search
+107
@@ -0,0 +1,107 @@
|
||||
# Inline source reveal with a bounded native editor
|
||||
|
||||
## Status
|
||||
|
||||
Implemented; validation results are recorded below.
|
||||
|
||||
## Context
|
||||
|
||||
A paragraph-wide source input exposes unrelated Markdown when the user clicks
|
||||
one formatted word or formula. Replacing native inputs while their Markdown
|
||||
changes also risks losing the IME marked range and the user's selection.
|
||||
Maintaining a second unrestricted rich document would increase memory and
|
||||
invalidation costs without resolving source ownership.
|
||||
|
||||
## Evidence
|
||||
|
||||
- [Projection](../../../../../nebula_app/src/gpui_shell/file_editor/inline_edit.rs)
|
||||
already preserves source ranges and untouched link destinations. A single
|
||||
revealed inline can therefore coexist with styled neighboring leaves.
|
||||
- The pinned component input accepts text decorations but no embedded image or
|
||||
formula elements. Embedded objects need separate render and edit spans.
|
||||
- Native input `set_value` suppresses Change events but resets local history and
|
||||
selection. It is safe for presentation changes only outside composition and
|
||||
with explicit cursor restoration. Shared document history remains authoritative.
|
||||
- The pinned reader joins selected TextViews with newlines. Multiple views in
|
||||
one mixed paragraph need paragraph-aware copy assembly.
|
||||
|
||||
## Decision
|
||||
|
||||
- Keep the canonical Markdown buffer and existing document history. The active
|
||||
native input is a presentation of one source span, not another document model.
|
||||
- Reveal the innermost supported inline at the collapsed caret. Translate the
|
||||
caret through source byte offsets when switching visibility; do not commit
|
||||
those switches to the source buffer or history.
|
||||
- Keep the input entity stable during ordinary editing and IME composition.
|
||||
Do not reproject a noncollapsed selection or an active marked range. Native
|
||||
composition gets the first opportunity to consume Escape/Enter/history keys.
|
||||
- Split paragraphs containing images or inline math at their object ranges.
|
||||
Only the active part owns a native input. Other parts retain rendered content.
|
||||
Source ranges, not formula text or image URLs, identify repeated objects.
|
||||
- Retain prepared still-image paths separately from editable source ranges.
|
||||
Image preparation stays in the existing background preparation path; rendering
|
||||
does not flatten images or synchronously access the filesystem.
|
||||
- Limit mixed paragraphs to 128 runs in addition to the existing 32 KiB block
|
||||
activation bound. Excessive fragmentation gets one literal source part.
|
||||
- Keep selection-view references weak and evict them with the virtual rows.
|
||||
Reassemble selected runs of a top-level mixed paragraph in source order when
|
||||
copying. Retain the existing outer selection policy for structured containers.
|
||||
- Compare the active input Rope before materializing a changed draft. Focus,
|
||||
selection and blink notifications do not allocate a draft String.
|
||||
- Record document history by comparing the Rope with the existing history head
|
||||
and copying only the changed spans. Update that head in place. This removes
|
||||
two full-document String copies from ordinary change handling.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- Expanding the entire paragraph on every click fails local source reveal.
|
||||
- A rendered object replaced by raw Markdown whenever a neighbor is edited
|
||||
fails the same interaction requirement for mixed paragraphs.
|
||||
- Recreating native inputs or forcing projection updates during composition
|
||||
invalidates the platform's marked range.
|
||||
- A WebView/DOM clone, a second full source editor, or retained per-block inputs
|
||||
introduce additional long-lived state and larger refresh surfaces.
|
||||
- Per-fragment Copy with the reader's default newline joining changes text that
|
||||
was originally one paragraph.
|
||||
- Trimming process working sets would change residency measurements without
|
||||
reducing the allocations retained by the document.
|
||||
|
||||
## Consequences
|
||||
|
||||
The existing document load, preview prefix, block, image and history budgets
|
||||
remain separate bounds; this decision does not assert a total process RSS limit.
|
||||
History comparison still scans text and insertion can move the suffix of its
|
||||
String head. Structural commands, saving and undo can still materialize the
|
||||
document. No general CPU percentage or memory reduction is claimed from these
|
||||
code changes.
|
||||
|
||||
Object boundaries participate in the existing native flex/wrap layout. Table
|
||||
cells retain their existing part ownership; this is not a redesign of table
|
||||
navigation or the reader's global selection protocol.
|
||||
|
||||
## Validation
|
||||
|
||||
The Windows `gpui-test-support` executable passed all 86 file-editor tests.
|
||||
They cover local reveal, first preview clicks, source and visible offsets,
|
||||
Chinese composition, duplicate formulas, image editing, cross-fragment selection
|
||||
and copying, saving and shared undo/redo. History tests include deletion of
|
||||
repeated suffixes so prefix and suffix scans cannot overlap. A prepared GIF
|
||||
fixture checks that mixed views retain a still preview and original source spans.
|
||||
|
||||
The Windows product check passed with `cargo check -p nebula --bin pebrel
|
||||
--features gpui-shell --tests --locked --offline --config build.incremental=false -j 2`.
|
||||
|
||||
`python3 scripts/check_architecture.py --base
|
||||
03ea19287156f0abcf6c9e233f72f0d0e74a5007` passed. The touched tracked files also
|
||||
passed `git diff --check`. The full workspace format check and all 53 architecture
|
||||
checker regression tests passed on that main-based integration tree.
|
||||
|
||||
## Supersedes
|
||||
|
||||
None.
|
||||
|
||||
## Revisit when
|
||||
|
||||
The component exposes source-aware inline layout and native embedded objects;
|
||||
or profiling shows the bounded active parse or document-history scan dominates
|
||||
typing latency; or table cells require independent embedded-object ownership.
|
||||
@@ -26,6 +26,7 @@ impl TextFileView {
|
||||
self.preview_task = None;
|
||||
self.revision += 1;
|
||||
self.stop_preview_selection_scroll();
|
||||
self.inline_views.borrow_mut().clear();
|
||||
for block in self.blocks.borrow_mut().iter_mut() {
|
||||
*block = None;
|
||||
}
|
||||
@@ -48,9 +49,8 @@ impl TextFileView {
|
||||
let block = self.outline.edit_block_at(cursor.anchor.min(self.input.read(cx).text().len()));
|
||||
self.resume_live_at(block, cursor.anchor, window, cx);
|
||||
if let Some(edit) = &self.live_edit {
|
||||
let text = &edit.projection.text;
|
||||
let start = text.floor_char_boundary(cursor.selection.start.min(text.len()));
|
||||
let end = text.floor_char_boundary(cursor.selection.end.min(text.len()));
|
||||
let start = edit.projection.visible_offset(cursor.selection.start);
|
||||
let end = edit.projection.visible_offset(cursor.selection.end);
|
||||
edit.input.update(cx, |input, cx| input.set_selected_range(start..end, cx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Source spans for mixed paragraphs. Only embedded objects split a native text
|
||||
//! run; surrounding Markdown remains untouched in the canonical document.
|
||||
|
||||
use super::block_structure::{EditPart, PartKind, StructureNode, add, span};
|
||||
use super::inline_edit::Marks;
|
||||
use markdown::mdast::Node;
|
||||
|
||||
const MAX_INLINE_RUNS: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct InlineRun {
|
||||
pub part: usize,
|
||||
pub marks: Marks,
|
||||
}
|
||||
|
||||
pub(super) fn contains_object(node: &Node) -> bool {
|
||||
is_object(node) || node.children().is_some_and(|children| children.iter().any(contains_object))
|
||||
}
|
||||
|
||||
fn is_object(node: &Node) -> bool {
|
||||
matches!(node, Node::InlineMath(_) | Node::Image(_) | Node::ImageReference(_))
|
||||
}
|
||||
|
||||
pub(super) fn build(
|
||||
node: &Node,
|
||||
source: &str,
|
||||
offset: usize,
|
||||
parts: &mut Vec<EditPart>,
|
||||
) -> Option<StructureNode> {
|
||||
let initial = parts.len();
|
||||
let heading = if let Node::Heading(heading) = node { Some(heading.depth) } else { None };
|
||||
let mut builder = Builder { source, offset, parts, runs: vec![], heading };
|
||||
let built = node.children().is_some_and(|children| builder.collect(children, Marks::default()));
|
||||
if !built {
|
||||
// Do not instantiate a TextView per tiny object in a pathological block.
|
||||
// A literal preview and a single source input keep this fallback bounded.
|
||||
builder.parts.truncate(initial);
|
||||
return Some(StructureNode::Literal(add(
|
||||
builder.parts,
|
||||
span(node, offset)?,
|
||||
PartKind::Source,
|
||||
heading,
|
||||
)));
|
||||
}
|
||||
Some(StructureNode::Inline(builder.runs))
|
||||
}
|
||||
|
||||
struct Builder<'a> {
|
||||
source: &'a str,
|
||||
offset: usize,
|
||||
parts: &'a mut Vec<EditPart>,
|
||||
runs: Vec<InlineRun>,
|
||||
heading: Option<u8>,
|
||||
}
|
||||
|
||||
impl Builder<'_> {
|
||||
fn collect(&mut self, nodes: &[Node], marks: Marks) -> bool {
|
||||
for node in nodes {
|
||||
if !is_object(node) && contains_object(node) {
|
||||
let mut inherited = marks;
|
||||
match node {
|
||||
Node::Strong(_) => inherited.bold = true,
|
||||
Node::Emphasis(_) => inherited.italic = true,
|
||||
Node::Delete(_) => inherited.strike = true,
|
||||
Node::Link(_) | Node::LinkReference(_) => inherited.link = true,
|
||||
_ => {},
|
||||
}
|
||||
if let Some(children) = node.children() {
|
||||
if !self.collect(children, inherited) {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(range) = span(node, self.offset) else { return false };
|
||||
if !is_object(node) {
|
||||
if let Some(previous) = self.runs.last() {
|
||||
let part = &mut self.parts[previous.part];
|
||||
if matches!(part.kind, PartKind::Rich)
|
||||
&& previous.marks == marks
|
||||
&& part.range.end <= range.start
|
||||
&& self
|
||||
.source
|
||||
.get(part.range.end..range.start)
|
||||
.is_some_and(|gap| gap.chars().all(char::is_whitespace))
|
||||
{
|
||||
part.range.end = range.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.runs.len() == MAX_INLINE_RUNS {
|
||||
return false;
|
||||
}
|
||||
let kind = if is_object(node) { PartKind::Source } else { PartKind::Rich };
|
||||
let part = add(self.parts, range, kind, self.heading);
|
||||
self.runs.push(InlineRun { part, marks });
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gpui_shell::file_editor::block_structure::BlockStructure;
|
||||
|
||||
fn parse(source: &str) -> BlockStructure {
|
||||
let mut options = markdown::ParseOptions::gfm();
|
||||
options.constructs.math_text = true;
|
||||
let Node::Root(root) = markdown::to_mdast(source, &options).unwrap() else { panic!() };
|
||||
BlockStructure::from_node(&root.children[0], source, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_objects_have_distinct_source_spans_and_keep_nested_marks() {
|
||||
let source = "前 **bold $x$ after**  $x$ 后";
|
||||
let structure = parse(source);
|
||||
let StructureNode::Inline(runs) = &structure.root else { panic!() };
|
||||
let formulas: Vec<_> = runs
|
||||
.iter()
|
||||
.filter(|run| &source[structure.parts[run.part].range.clone()] == "$x$")
|
||||
.collect();
|
||||
assert_eq!(formulas.len(), 2);
|
||||
assert!(formulas[0].marks.bold);
|
||||
assert!(!formulas[1].marks.bold);
|
||||
assert_ne!(
|
||||
structure.parts[formulas[0].part].range,
|
||||
structure.parts[formulas[1].part].range
|
||||
);
|
||||
assert!(structure.parts.iter().any(|part| &source[part.range.clone()] == ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparing_inline_images_keeps_source_offsets_and_uses_a_still_preview() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let file = std::fs::File::create(directory.path().join("tiny.gif")).unwrap();
|
||||
image::codecs::gif::GifEncoder::new(file)
|
||||
.encode_frame(image::Frame::new(image::RgbaImage::from_pixel(
|
||||
2,
|
||||
2,
|
||||
image::Rgba([0, 0, 0, 255]),
|
||||
)))
|
||||
.unwrap();
|
||||
let source = "Before  after";
|
||||
let outline = crate::gpui_shell::file_editor::outline::Outline::prepare(
|
||||
source,
|
||||
Some(directory.path()),
|
||||
);
|
||||
let structure = outline.structures[0].as_ref().unwrap();
|
||||
let image = structure
|
||||
.parts
|
||||
.iter()
|
||||
.find(|part| &source[part.range.clone()] == "")
|
||||
.unwrap();
|
||||
let preview = image.preview.as_deref().unwrap();
|
||||
assert!(preview.contains(".png)"), "{preview}");
|
||||
assert_eq!(outline.source_ranges[0], 0..source.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excessive_objects_have_one_literal_part_and_no_partial_allocation() {
|
||||
let source = "$x$ ".repeat(MAX_INLINE_RUNS + 1);
|
||||
let structure = parse(&source);
|
||||
assert!(matches!(structure.root, StructureNode::Literal(0)));
|
||||
assert_eq!(structure.parts.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Source-backed editable parts. Containers own layout, while each input changes
|
||||
//! only its content span; pipes, list markers and fences never enter that input.
|
||||
|
||||
pub(super) use super::block_inline::InlineRun;
|
||||
use markdown::mdast::{AlignKind, Node};
|
||||
use std::ops::Range;
|
||||
|
||||
@@ -17,6 +18,7 @@ pub(super) struct EditPart {
|
||||
pub range: Range<usize>,
|
||||
pub kind: PartKind,
|
||||
pub heading: Option<u8>,
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -31,6 +33,8 @@ pub(super) struct ListItem {
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) enum StructureNode {
|
||||
Text(usize),
|
||||
Inline(Vec<InlineRun>),
|
||||
Literal(usize),
|
||||
Code { part: usize, span: Range<usize>, language: String },
|
||||
Math { part: usize, span: Range<usize> },
|
||||
List(Vec<ListItem>),
|
||||
@@ -49,7 +53,9 @@ impl BlockStructure {
|
||||
if !matches!(
|
||||
node,
|
||||
Node::List(_) | Node::Table(_) | Node::Code(_) | Node::Math(_) | Node::Blockquote(_)
|
||||
) {
|
||||
) && !(matches!(node, Node::Paragraph(_) | Node::Heading(_))
|
||||
&& super::block_inline::contains_object(node))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
@@ -78,6 +84,7 @@ impl BlockStructure {
|
||||
let old = self.parts[part].range.clone();
|
||||
let delta = length as isize - old.len() as isize;
|
||||
self.parts[part].range.end = old.start + length;
|
||||
self.parts[part].preview = None;
|
||||
for (i, edit) in self.parts.iter_mut().enumerate() {
|
||||
if i != part {
|
||||
shift(&mut edit.range, &old, delta);
|
||||
@@ -90,7 +97,7 @@ impl BlockStructure {
|
||||
impl StructureNode {
|
||||
fn shift(&mut self, old: &Range<usize>, delta: isize) {
|
||||
match self {
|
||||
Self::Text(_) => {},
|
||||
Self::Text(_) | Self::Literal(_) | Self::Inline(_) => {},
|
||||
Self::Code { span, .. } | Self::Math { span, .. } | Self::Table { span, .. } => {
|
||||
shift_container(span, old, delta);
|
||||
},
|
||||
@@ -120,7 +127,11 @@ impl StructureNode {
|
||||
item.children.iter().find_map(|child| child.list_item(part)).or_else(|| {
|
||||
item.children
|
||||
.iter()
|
||||
.any(|child| matches!(child, Self::Text(id) if *id == part))
|
||||
.any(|child| match child {
|
||||
Self::Text(id) | Self::Literal(id) => *id == part,
|
||||
Self::Inline(runs) => runs.iter().any(|run| run.part == part),
|
||||
_ => false,
|
||||
})
|
||||
.then_some(item)
|
||||
})
|
||||
}),
|
||||
@@ -160,19 +171,19 @@ fn shift_container(range: &mut Range<usize>, old: &Range<usize>, delta: isize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn span(node: &Node, offset: usize) -> Option<Range<usize>> {
|
||||
pub(super) fn span(node: &Node, offset: usize) -> Option<Range<usize>> {
|
||||
let position = node.position()?;
|
||||
Some(position.start.offset.checked_sub(offset)?..position.end.offset.checked_sub(offset)?)
|
||||
}
|
||||
|
||||
fn add(
|
||||
pub(super) fn add(
|
||||
parts: &mut Vec<EditPart>,
|
||||
range: Range<usize>,
|
||||
kind: PartKind,
|
||||
heading: Option<u8>,
|
||||
) -> usize {
|
||||
let index = parts.len();
|
||||
parts.push(EditPart { range, kind, heading });
|
||||
parts.push(EditPart { range, kind, heading, preview: None });
|
||||
index
|
||||
}
|
||||
|
||||
@@ -280,6 +291,9 @@ fn build(
|
||||
let part = add(parts, body, PartKind::Math, None);
|
||||
StructureNode::Math { part, span: range }
|
||||
},
|
||||
Node::Paragraph(_) | Node::Heading(_) if super::block_inline::contains_object(node) => {
|
||||
return super::block_inline::build(node, source, offset, parts);
|
||||
},
|
||||
Node::Paragraph(_) | Node::Heading(_) => StructureNode::Text(add(
|
||||
parts,
|
||||
range,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! presentation: its lifetime must not decide which edits can be undone.
|
||||
|
||||
use super::inline_edit::changed_span;
|
||||
use gpui_component::Rope;
|
||||
use std::{
|
||||
ops::Range,
|
||||
time::{Duration, Instant},
|
||||
@@ -21,6 +22,36 @@ impl Change {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the changed byte range without materializing the whole Rope.
|
||||
///
|
||||
/// `InputState::value()` turns its backing Rope into a new String. Ordinary
|
||||
/// typing only needs the small replacement at the cursor, so compare the
|
||||
/// character streams and copy just that replacement into the history state.
|
||||
fn changed_rope_span(before: &str, after: &Rope) -> (Range<usize>, Range<usize>) {
|
||||
let mut prefix = 0;
|
||||
let mut after_chars = after.chars();
|
||||
for (offset, before_char) in before.char_indices() {
|
||||
if after_chars.next() == Some(before_char) {
|
||||
prefix = offset + before_char.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut before_suffix = before[prefix..].chars().rev();
|
||||
let after_tail = after.slice(prefix..);
|
||||
let mut after_suffix = after_tail.chars_at(after_tail.len());
|
||||
let mut suffix = 0;
|
||||
while let (Some(before_char), Some(after_char)) = (before_suffix.next(), after_suffix.prev()) {
|
||||
if before_char != after_char {
|
||||
break;
|
||||
}
|
||||
suffix += before_char.len_utf8();
|
||||
}
|
||||
|
||||
(prefix..before.len() - suffix, prefix..after.len() - suffix)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EditHistory {
|
||||
current: String,
|
||||
@@ -39,6 +70,26 @@ impl EditHistory {
|
||||
self.last_edit = None;
|
||||
}
|
||||
|
||||
/// Record a local edit directly from the input's Rope.
|
||||
///
|
||||
/// This is the hot path for ordinary source typing. The Rope is scanned
|
||||
/// without creating a document-sized String, and `current` is edited in
|
||||
/// place so the history does not retain a full snapshot per keystroke.
|
||||
pub fn record_rope(&mut self, text: &Rope) {
|
||||
let (before, after) = changed_rope_span(&self.current, text);
|
||||
if before.is_empty() && after.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let change = Change {
|
||||
start: before.start,
|
||||
before: self.current[before.clone()].to_owned(),
|
||||
after: text.slice(after).to_string(),
|
||||
};
|
||||
self.current.replace_range(before, &change.after);
|
||||
self.push_change(change);
|
||||
}
|
||||
|
||||
pub fn record(&mut self, text: &str) {
|
||||
if text == self.current {
|
||||
return;
|
||||
@@ -46,10 +97,14 @@ impl EditHistory {
|
||||
let (before, after) = changed_span(&self.current, text);
|
||||
let change = Change {
|
||||
start: before.start,
|
||||
before: self.current[before].to_owned(),
|
||||
before: self.current[before.clone()].to_owned(),
|
||||
after: text[after].to_owned(),
|
||||
};
|
||||
self.current = text.to_owned();
|
||||
self.current.replace_range(before, &change.after);
|
||||
self.push_change(change);
|
||||
}
|
||||
|
||||
fn push_change(&mut self, change: Change) {
|
||||
self.bytes -= self.redo.iter().flatten().map(Change::bytes).sum::<usize>();
|
||||
self.redo.clear();
|
||||
let now = Instant::now();
|
||||
@@ -99,6 +154,15 @@ impl EditHistory {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deleting_a_repeated_suffix_does_not_overlap_the_common_prefix() {
|
||||
let mut history = EditHistory::default();
|
||||
history.reset("中文abc中文abc");
|
||||
history.record_rope(&Rope::from("中文abc"));
|
||||
assert_eq!(history.travel(false).unwrap().0, "中文abc中文abc");
|
||||
assert_eq!(history.travel(true).unwrap().0, "中文abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_spans_input_lifetimes_and_discards_redo_after_a_new_edit() {
|
||||
let mut history = EditHistory::default();
|
||||
@@ -113,4 +177,15 @@ mod tests {
|
||||
assert!(history.travel(true).is_none());
|
||||
assert_eq!(history.travel(false).unwrap().0, "# 中文😀\n\nbody");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rope_records_only_the_local_unicode_change() {
|
||||
let mut history = EditHistory::default();
|
||||
history.reset("prefix 中文 suffix");
|
||||
|
||||
history.record_rope(&Rope::from("prefix 🌿 suffix"));
|
||||
assert_eq!(history.current, "prefix 🌿 suffix");
|
||||
assert_eq!(history.travel(false).unwrap().0, "prefix 中文 suffix");
|
||||
assert_eq!(history.travel(true).unwrap().0, "prefix 🌿 suffix");
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,13 @@ struct Leaf {
|
||||
text: String,
|
||||
marks: Marks,
|
||||
raw: bool,
|
||||
revealable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Expandable {
|
||||
source: Range<usize>,
|
||||
visible: Range<usize>,
|
||||
}
|
||||
|
||||
pub(super) struct Projection {
|
||||
@@ -27,6 +34,8 @@ pub(super) struct Projection {
|
||||
pub text: String,
|
||||
leaves: Vec<Leaf>,
|
||||
containers: Vec<Range<usize>>,
|
||||
expandables: Vec<Expandable>,
|
||||
reveal_range: Option<Range<usize>>,
|
||||
pub rich: bool,
|
||||
literal: bool,
|
||||
}
|
||||
@@ -49,6 +58,39 @@ pub(super) fn changed_span(before: &str, after: &str) -> (Range<usize>, Range<us
|
||||
(prefix..before.len() - suffix, prefix..after.len() - suffix)
|
||||
}
|
||||
|
||||
fn shift_reveal(
|
||||
reveal: &Range<usize>,
|
||||
changed: &Range<usize>,
|
||||
inserted: usize,
|
||||
source_len: usize,
|
||||
) -> Range<usize> {
|
||||
let delta = inserted as isize - changed.len() as isize;
|
||||
let (start, end) = if changed.is_empty() {
|
||||
if changed.start < reveal.start {
|
||||
(reveal.start.saturating_add_signed(delta), reveal.end.saturating_add_signed(delta))
|
||||
} else if changed.start <= reveal.end {
|
||||
(reveal.start, reveal.end.saturating_add_signed(delta))
|
||||
} else {
|
||||
(reveal.start, reveal.end)
|
||||
}
|
||||
} else if changed.end <= reveal.start {
|
||||
(reveal.start.saturating_add_signed(delta), reveal.end.saturating_add_signed(delta))
|
||||
} else if changed.start >= reveal.end {
|
||||
(reveal.start, reveal.end)
|
||||
} else {
|
||||
let start = reveal.start.min(changed.start);
|
||||
let end = if changed.end <= reveal.end {
|
||||
reveal.end.saturating_add_signed(delta)
|
||||
} else {
|
||||
changed.start.saturating_add(inserted)
|
||||
};
|
||||
(start, end.max(start))
|
||||
};
|
||||
let start = start.min(source_len);
|
||||
let end = end.min(source_len).max(start);
|
||||
start..end
|
||||
}
|
||||
|
||||
impl Projection {
|
||||
/// During a composition or a trailing-space edit, Markdown can temporarily
|
||||
/// be incomplete. Retain the existing leaf/mark identity until it parses to
|
||||
@@ -58,8 +100,18 @@ impl Projection {
|
||||
*self = Self::raw(source);
|
||||
return true;
|
||||
}
|
||||
let parsed = Self::new(source);
|
||||
if parsed.text == text {
|
||||
let (source_before, source_after) = changed_span(&self.source, source);
|
||||
let reveal = self
|
||||
.reveal_range
|
||||
.as_ref()
|
||||
.map(|range| shift_reveal(range, &source_before, source_after.len(), source.len()));
|
||||
let parsed = Self::with_reveal(source, reveal.clone());
|
||||
let replaced_reveal = self.reveal_range.as_ref().is_some_and(|range| {
|
||||
source_before.start <= range.start && source_before.end >= range.end
|
||||
});
|
||||
if parsed.text == text
|
||||
&& (self.reveal_range.is_none() || parsed.revealed().is_some() || replaced_reveal)
|
||||
{
|
||||
*self = parsed;
|
||||
return true;
|
||||
}
|
||||
@@ -69,7 +121,6 @@ impl Projection {
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
let (source_before, source_after) = changed_span(&self.source, source);
|
||||
let shift =
|
||||
|range: &mut Range<usize>, change: &Range<usize>, inserted: usize, affected: bool| {
|
||||
let delta = inserted as isize - change.len() as isize;
|
||||
@@ -93,15 +144,29 @@ impl Projection {
|
||||
}
|
||||
self.source = source.to_owned();
|
||||
self.text = text.to_owned();
|
||||
self.reveal_range = reveal;
|
||||
self.rebuild_expandables();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn new(source: &str) -> Self {
|
||||
Self::with_reveal(source, None)
|
||||
}
|
||||
|
||||
/// Build a rich projection while exposing one inline node's source syntax.
|
||||
///
|
||||
/// The returned text contains the selected node's exact source range, while
|
||||
/// all unrelated leaves retain their rendered text and marks. This keeps a
|
||||
/// single native input usable for local source reveal without making the
|
||||
/// surrounding paragraph raw.
|
||||
pub fn with_reveal(source: &str, reveal: Option<Range<usize>>) -> Self {
|
||||
let mut result = Self {
|
||||
source: source.to_owned(),
|
||||
text: String::new(),
|
||||
leaves: vec![],
|
||||
containers: vec![],
|
||||
expandables: vec![],
|
||||
reveal_range: None,
|
||||
rich: false,
|
||||
literal: false,
|
||||
};
|
||||
@@ -129,11 +194,21 @@ impl Projection {
|
||||
}
|
||||
if result.leaves.is_empty() {
|
||||
if result.rich {
|
||||
result.push(source.len()..source.len(), String::new(), Marks::default(), false);
|
||||
return result;
|
||||
result.push(
|
||||
source.len()..source.len(),
|
||||
String::new(),
|
||||
Marks::default(),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
result.rich = source.is_empty();
|
||||
result.push(0..source.len(), source.to_owned(), Marks::default(), true, false);
|
||||
}
|
||||
result.rich = source.is_empty();
|
||||
result.push(0..source.len(), source.to_owned(), Marks::default(), true);
|
||||
}
|
||||
result.rebuild_expandables();
|
||||
if let Some(range) = reveal.and_then(|range| result.normalize_expandable(range)) {
|
||||
result.apply_reveal(range);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -144,10 +219,12 @@ impl Projection {
|
||||
text: String::new(),
|
||||
leaves: vec![],
|
||||
containers: vec![],
|
||||
expandables: vec![],
|
||||
reveal_range: None,
|
||||
rich: false,
|
||||
literal: true,
|
||||
};
|
||||
result.push(0..source.len(), source.to_owned(), Marks::default(), true);
|
||||
result.push(0..source.len(), source.to_owned(), Marks::default(), true, false);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -155,10 +232,10 @@ impl Projection {
|
||||
let Some(position) = node.position() else { return };
|
||||
let span = position.start.offset..position.end.offset;
|
||||
match node {
|
||||
Node::Text(text) => self.push(span, text.value.clone(), marks, false),
|
||||
Node::Text(text) => self.push(span, text.value.clone(), marks, false, false),
|
||||
Node::InlineCode(code) => {
|
||||
marks.code = true;
|
||||
self.push(span, code.value.clone(), marks, false);
|
||||
self.push(span, code.value.clone(), marks, false, true);
|
||||
},
|
||||
Node::Strong(_)
|
||||
| Node::Emphasis(_)
|
||||
@@ -176,23 +253,124 @@ impl Projection {
|
||||
self.collect(child, marks);
|
||||
}
|
||||
},
|
||||
Node::Break(_) => self.push(span, "\n".to_owned(), marks, false),
|
||||
Node::Break(_) => self.push(span, "\n".to_owned(), marks, false, false),
|
||||
Node::Html(html) if matches!(html.value.as_str(), "<br>" | "<br/>" | "<br />") => {
|
||||
self.push(span, "\n".to_owned(), marks, true);
|
||||
self.push(span, "\n".to_owned(), marks, true, false);
|
||||
},
|
||||
_ => {
|
||||
// Embedded objects keep an explicit Markdown representation in
|
||||
// the focused block; their untouched source is never serialized.
|
||||
let text = self.source[span.clone()].to_owned();
|
||||
self.push(span, text, marks, true);
|
||||
self.push(span, text, marks, true, true);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, source: Range<usize>, text: String, marks: Marks, raw: bool) {
|
||||
fn push(
|
||||
&mut self,
|
||||
source: Range<usize>,
|
||||
text: String,
|
||||
marks: Marks,
|
||||
raw: bool,
|
||||
revealable: bool,
|
||||
) {
|
||||
let start = self.text.len();
|
||||
self.text.push_str(&text);
|
||||
self.leaves.push(Leaf { source, visible: start..self.text.len(), text, marks, raw });
|
||||
self.leaves.push(Leaf {
|
||||
source,
|
||||
visible: start..self.text.len(),
|
||||
text,
|
||||
marks,
|
||||
raw,
|
||||
revealable,
|
||||
});
|
||||
}
|
||||
|
||||
fn rebuild_expandables(&mut self) {
|
||||
let mut expandables = Vec::with_capacity(self.containers.len() + self.leaves.len());
|
||||
for source in &self.containers {
|
||||
if let Some(visible) = self.visible_range_for_source(source) {
|
||||
expandables.push(Expandable { source: source.clone(), visible });
|
||||
}
|
||||
}
|
||||
for leaf in &self.leaves {
|
||||
if leaf.revealable {
|
||||
expandables.push(Expandable {
|
||||
source: leaf.source.clone(),
|
||||
visible: leaf.visible.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.expandables = expandables;
|
||||
}
|
||||
|
||||
fn visible_range_for_source(&self, source: &Range<usize>) -> Option<Range<usize>> {
|
||||
let mut visible: Option<Range<usize>> = None;
|
||||
for leaf in &self.leaves {
|
||||
if leaf.source.start < source.start || leaf.source.end > source.end {
|
||||
continue;
|
||||
}
|
||||
if leaf.visible.is_empty() {
|
||||
continue;
|
||||
}
|
||||
visible = Some(match visible {
|
||||
Some(current) => {
|
||||
current.start.min(leaf.visible.start)..current.end.max(leaf.visible.end)
|
||||
},
|
||||
None => leaf.visible.clone(),
|
||||
});
|
||||
}
|
||||
visible
|
||||
}
|
||||
|
||||
fn normalize_expandable(&self, source: Range<usize>) -> Option<Range<usize>> {
|
||||
self.expandables
|
||||
.iter()
|
||||
.find(|expandable| expandable.source == source)
|
||||
.map(|expandable| expandable.source.clone())
|
||||
}
|
||||
|
||||
fn apply_reveal(&mut self, source: Range<usize>) {
|
||||
let Some(raw) = self.source.get(source.clone()).map(str::to_owned) else { return };
|
||||
let old_leaves = std::mem::take(&mut self.leaves);
|
||||
let mut leaves = Vec::with_capacity(old_leaves.len());
|
||||
let mut text = String::with_capacity(self.text.len() + raw.len());
|
||||
let mut inserted = false;
|
||||
|
||||
for mut leaf in old_leaves {
|
||||
let overlaps = leaf.source.start < source.end && source.start < leaf.source.end;
|
||||
if overlaps {
|
||||
if !inserted {
|
||||
let start = text.len();
|
||||
text.push_str(&raw);
|
||||
leaves.push(Leaf {
|
||||
source: source.clone(),
|
||||
visible: start..text.len(),
|
||||
text: raw.clone(),
|
||||
marks: Marks::default(),
|
||||
raw: true,
|
||||
revealable: true,
|
||||
});
|
||||
inserted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let start = text.len();
|
||||
text.push_str(&leaf.text);
|
||||
leaf.visible = start..text.len();
|
||||
leaves.push(leaf);
|
||||
}
|
||||
|
||||
if !inserted {
|
||||
self.leaves = leaves;
|
||||
return;
|
||||
}
|
||||
self.text = text;
|
||||
self.leaves = leaves;
|
||||
self.containers
|
||||
.retain(|container| !(source.start <= container.start && container.end <= source.end));
|
||||
self.reveal_range = Some(source);
|
||||
self.rebuild_expandables();
|
||||
}
|
||||
|
||||
pub fn marks(&self) -> impl Iterator<Item = (Range<usize>, Marks)> + '_ {
|
||||
@@ -237,15 +415,90 @@ impl Projection {
|
||||
(Self::new(&source).text == self.text).then_some(source)
|
||||
}
|
||||
|
||||
/// Translate a source byte offset to the corresponding projected offset.
|
||||
///
|
||||
/// Offsets that fall inside Markdown delimiters map to the nearest visible
|
||||
/// leaf boundary. Both sides are clamped to a UTF-8 character boundary.
|
||||
pub fn visible_offset(&self, offset: usize) -> usize {
|
||||
let offset = self.source.floor_char_boundary(offset.min(self.source.len()));
|
||||
let leaf = self
|
||||
.leaves
|
||||
.iter()
|
||||
.find(|leaf| leaf.source.end >= offset)
|
||||
.or_else(|| self.leaves.last())
|
||||
.unwrap();
|
||||
let local = offset.saturating_sub(leaf.source.start).min(leaf.text.len());
|
||||
leaf.visible.start + leaf.text.floor_char_boundary(local)
|
||||
let local = offset.saturating_sub(leaf.source.start).min(leaf.source.len());
|
||||
let raw = &self.source[leaf.source.clone()];
|
||||
let local = if leaf.marks.code {
|
||||
inline_code_visible_offset(raw, &leaf.text, local)
|
||||
} else {
|
||||
visible_from_source(raw, &leaf.text, local)
|
||||
}
|
||||
.unwrap_or_else(|| leaf.text.floor_char_boundary(local.min(leaf.text.len())));
|
||||
leaf.visible.start + local
|
||||
}
|
||||
|
||||
/// Translate a projected byte offset back to the source buffer.
|
||||
///
|
||||
/// This is the inverse of `visible_offset` for leaf content. A projected
|
||||
/// offset at a formatting boundary resolves to the adjacent source boundary;
|
||||
/// revealed raw leaves therefore map delimiters exactly.
|
||||
pub fn source_offset(&self, offset: usize) -> usize {
|
||||
let offset = self.text.floor_char_boundary(offset.min(self.text.len()));
|
||||
let leaf = self
|
||||
.leaves
|
||||
.iter()
|
||||
.find(|leaf| {
|
||||
!leaf.visible.is_empty()
|
||||
&& leaf.visible.start <= offset
|
||||
&& offset < leaf.visible.end
|
||||
})
|
||||
.or_else(|| {
|
||||
self.leaves
|
||||
.iter()
|
||||
.find(|leaf| !leaf.visible.is_empty() && leaf.visible.start == offset)
|
||||
})
|
||||
.or_else(|| self.leaves.iter().find(|leaf| leaf.visible.end >= offset))
|
||||
.or_else(|| self.leaves.last())
|
||||
.unwrap();
|
||||
let local = offset.saturating_sub(leaf.visible.start).min(leaf.text.len());
|
||||
let raw = &self.source[leaf.source.clone()];
|
||||
let local = if leaf.marks.code {
|
||||
inline_code_source_offset(raw, &leaf.text, local)
|
||||
} else {
|
||||
raw_offset(raw, &leaf.text, local)
|
||||
}
|
||||
.unwrap_or_else(|| leaf.text.floor_char_boundary(local));
|
||||
leaf.source.start + local
|
||||
}
|
||||
|
||||
/// Return the innermost expandable inline at a projected byte offset.
|
||||
///
|
||||
/// When this projection already exposes a raw inline, its range wins at the
|
||||
/// end boundary too. This keeps hit testing stable while the source is
|
||||
/// temporarily incomplete during IME or delimiter edits.
|
||||
pub fn reveal_at(&self, visible_offset: usize) -> Option<Range<usize>> {
|
||||
let offset = self.text.floor_char_boundary(visible_offset.min(self.text.len()));
|
||||
if let Some(range) = &self.reveal_range
|
||||
&& let Some(expandable) = self.expandables.iter().find(|expandable| {
|
||||
expandable.source == *range
|
||||
&& expandable.visible.start <= offset
|
||||
&& offset <= expandable.visible.end
|
||||
})
|
||||
{
|
||||
return Some(expandable.source.clone());
|
||||
}
|
||||
self.expandables
|
||||
.iter()
|
||||
.filter(|expandable| {
|
||||
expandable.visible.start <= offset && offset < expandable.visible.end
|
||||
})
|
||||
.min_by_key(|expandable| (expandable.source.len(), expandable.source.start))
|
||||
.map(|expandable| expandable.source.clone())
|
||||
}
|
||||
|
||||
pub fn revealed(&self) -> Option<Range<usize>> {
|
||||
self.reveal_range.clone()
|
||||
}
|
||||
|
||||
/// Translate a visible edit into source edits. Deleting across formatting
|
||||
@@ -359,19 +612,24 @@ fn raw_offset(raw: &str, text: &str, offset: usize) -> Option<usize> {
|
||||
let (mut source, mut visible) = (0, 0);
|
||||
while visible < offset {
|
||||
let tail = &raw[source..];
|
||||
if tail.starts_with('\\') && tail.as_bytes().get(1).is_some_and(u8::is_ascii_punctuation) {
|
||||
if tail.as_bytes().first() == Some(&b'\\')
|
||||
&& tail.as_bytes().get(1).is_some_and(u8::is_ascii_punctuation)
|
||||
{
|
||||
source += 2;
|
||||
visible += 1;
|
||||
} else if tail.starts_with("\r\n") {
|
||||
source += 2;
|
||||
visible += 1;
|
||||
} else if tail.starts_with('&') {
|
||||
} else if tail.as_bytes().first() == Some(&b'&') {
|
||||
let length = tail.find(';').filter(|end| *end < 40).map(|end| end + 1);
|
||||
if let Some(length) = length {
|
||||
let decoded = html_escape::decode_html_entities(&tail[..length]);
|
||||
if decoded.as_ref() != &tail[..length]
|
||||
&& text[visible..].starts_with(decoded.as_ref())
|
||||
{
|
||||
if visible + decoded.len() > offset {
|
||||
return Some(source);
|
||||
}
|
||||
source += length;
|
||||
visible += decoded.len();
|
||||
continue;
|
||||
@@ -381,7 +639,7 @@ fn raw_offset(raw: &str, text: &str, offset: usize) -> Option<usize> {
|
||||
visible += 1;
|
||||
} else {
|
||||
let ch = tail.chars().next()?;
|
||||
if !text[visible..].starts_with(ch) {
|
||||
if text[visible..].chars().next() != Some(ch) {
|
||||
return None;
|
||||
}
|
||||
source += ch.len_utf8();
|
||||
@@ -391,6 +649,133 @@ fn raw_offset(raw: &str, text: &str, offset: usize) -> Option<usize> {
|
||||
(visible == offset).then_some(source)
|
||||
}
|
||||
|
||||
fn inline_code_content(raw: &str) -> Option<Range<usize>> {
|
||||
let fence = raw.bytes().take_while(|byte| *byte == b'`').count();
|
||||
if fence == 0 || raw.len() < fence * 2 {
|
||||
return None;
|
||||
}
|
||||
let mut start = fence;
|
||||
let mut end = raw.len() - fence;
|
||||
if start < end
|
||||
&& raw.as_bytes()[start] == b' '
|
||||
&& raw.as_bytes()[end - 1] == b' '
|
||||
&& raw[start..end].bytes().any(|byte| byte != b' ')
|
||||
{
|
||||
start += 1;
|
||||
end -= 1;
|
||||
}
|
||||
Some(start..end)
|
||||
}
|
||||
|
||||
fn inline_code_visible_offset(raw: &str, text: &str, offset: usize) -> Option<usize> {
|
||||
let content = inline_code_content(raw)?;
|
||||
if offset <= content.start {
|
||||
return Some(0);
|
||||
}
|
||||
if offset >= content.end {
|
||||
return Some(text.len());
|
||||
}
|
||||
let mut source = content.start;
|
||||
let mut visible = 0;
|
||||
while source < offset {
|
||||
let tail = &raw[source..content.end];
|
||||
let (source_length, visible_length) = if tail.starts_with("\r\n") {
|
||||
(2, 1)
|
||||
} else if tail.as_bytes().first().is_some_and(|byte| matches!(byte, b'\r' | b'\n')) {
|
||||
(1, 1)
|
||||
} else {
|
||||
let ch = tail.chars().next()?;
|
||||
(ch.len_utf8(), ch.len_utf8())
|
||||
};
|
||||
if source + source_length > offset || visible + visible_length > text.len() {
|
||||
return None;
|
||||
}
|
||||
source += source_length;
|
||||
visible += visible_length;
|
||||
}
|
||||
(source == offset).then_some(visible)
|
||||
}
|
||||
|
||||
fn inline_code_source_offset(raw: &str, text: &str, offset: usize) -> Option<usize> {
|
||||
let content = inline_code_content(raw)?;
|
||||
if offset == 0 {
|
||||
return Some(content.start);
|
||||
}
|
||||
if offset >= text.len() {
|
||||
return Some(content.end);
|
||||
}
|
||||
let mut source = content.start;
|
||||
let mut visible = 0;
|
||||
while source < content.end {
|
||||
if visible >= offset {
|
||||
return Some(source);
|
||||
}
|
||||
let tail = &raw[source..content.end];
|
||||
let (source_length, visible_length) = if tail.starts_with("\r\n") {
|
||||
(2, 1)
|
||||
} else if tail.as_bytes().first().is_some_and(|byte| matches!(byte, b'\r' | b'\n')) {
|
||||
(1, 1)
|
||||
} else {
|
||||
let ch = tail.chars().next()?;
|
||||
(ch.len_utf8(), ch.len_utf8())
|
||||
};
|
||||
if visible + visible_length > text.len() {
|
||||
return None;
|
||||
}
|
||||
source += source_length;
|
||||
visible += visible_length;
|
||||
}
|
||||
(visible == offset).then_some(source)
|
||||
}
|
||||
|
||||
fn visible_from_source(raw: &str, text: &str, offset: usize) -> Option<usize> {
|
||||
if raw == text {
|
||||
return Some(offset.min(raw.len()));
|
||||
}
|
||||
let mut source = 0;
|
||||
let mut visible = 0;
|
||||
while source < offset {
|
||||
let tail = &raw[source..];
|
||||
let (source_length, visible_length) = if tail.as_bytes().first() == Some(&b'\\')
|
||||
&& tail.as_bytes().get(1).is_some_and(u8::is_ascii_punctuation)
|
||||
{
|
||||
(2, 1)
|
||||
} else if tail.starts_with("\r\n") {
|
||||
(2, 1)
|
||||
} else if tail.as_bytes().first() == Some(&b'&') {
|
||||
let length = tail.find(';').filter(|end| *end < 40).map(|end| end + 1);
|
||||
if let Some(length) = length {
|
||||
let decoded = html_escape::decode_html_entities(&tail[..length]);
|
||||
if decoded.as_ref() != &tail[..length]
|
||||
&& text[visible..].starts_with(decoded.as_ref())
|
||||
{
|
||||
(length, decoded.len())
|
||||
} else {
|
||||
let ch = tail.chars().next()?;
|
||||
(ch.len_utf8(), ch.len_utf8())
|
||||
}
|
||||
} else {
|
||||
let ch = tail.chars().next()?;
|
||||
(ch.len_utf8(), ch.len_utf8())
|
||||
}
|
||||
} else {
|
||||
let ch = tail.chars().next()?;
|
||||
if text[visible..].chars().next() != Some(ch) {
|
||||
return None;
|
||||
}
|
||||
(ch.len_utf8(), ch.len_utf8())
|
||||
};
|
||||
if source + source_length > offset {
|
||||
// A source offset inside an escape, entity, or line-ending token
|
||||
// has no distinct projected position; keep it at the token start.
|
||||
return Some(visible);
|
||||
}
|
||||
source += source_length;
|
||||
visible += visible_length;
|
||||
}
|
||||
(source == offset).then_some(visible)
|
||||
}
|
||||
|
||||
fn escape_text(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
for ch in text.chars() {
|
||||
@@ -408,7 +793,13 @@ fn inline_code(text: &str) -> String {
|
||||
}
|
||||
let length = text.split(|ch| ch != '`').map(str::len).max().unwrap_or(0) + 1;
|
||||
let fence = "`".repeat(length);
|
||||
let padding = if text.starts_with(['`', ' ']) || text.ends_with(['`', ' ']) { " " } else { "" };
|
||||
let padding = if text.as_bytes().first().is_some_and(|byte| matches!(byte, b'`' | b' '))
|
||||
|| text.as_bytes().last().is_some_and(|byte| matches!(byte, b'`' | b' '))
|
||||
{
|
||||
" "
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("{fence}{padding}{text}{padding}{fence}")
|
||||
}
|
||||
|
||||
@@ -495,4 +886,156 @@ mod tests {
|
||||
assert_eq!(source, after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_exposes_only_the_clicked_formatted_inline() {
|
||||
let source = "left **粗体** middle [link](url \"title\") right";
|
||||
let folded = Projection::new(source);
|
||||
assert_eq!(folded.text, "left 粗体 middle link right");
|
||||
|
||||
let bold_visible = folded.text.find("粗体").unwrap();
|
||||
let bold_start = source.find("**粗体**").unwrap();
|
||||
let bold = bold_start..bold_start + "**粗体**".len();
|
||||
assert_eq!(folded.reveal_at(bold_visible), Some(bold.clone()));
|
||||
|
||||
let revealed = Projection::with_reveal(source, Some(bold.clone()));
|
||||
assert_eq!(revealed.text, "left **粗体** middle link right");
|
||||
assert_eq!(revealed.revealed(), Some(bold));
|
||||
assert_eq!(&revealed.text[0..5], "left ");
|
||||
assert_eq!(&revealed.text[5.."left **粗体**".len()], "**粗体**");
|
||||
|
||||
let link_visible = revealed.text.find("link").unwrap();
|
||||
let link_start = source.find("[link](url \"title\")").unwrap();
|
||||
let link = link_start..link_start + "[link](url \"title\")".len();
|
||||
assert_eq!(revealed.reveal_at(link_visible), Some(link.clone()));
|
||||
let link_revealed = Projection::with_reveal(source, Some(link.clone()));
|
||||
assert_eq!(link_revealed.text, "left 粗体 middle [link](url \"title\") right");
|
||||
assert_eq!(link_revealed.revealed(), Some(link));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_picks_the_innermost_nested_mark() {
|
||||
let source = "**outer *inner***";
|
||||
let folded = Projection::new(source);
|
||||
assert_eq!(folded.text, "outer inner");
|
||||
|
||||
let outer = 0..source.len();
|
||||
let inner_start = source.find("*inner*").unwrap();
|
||||
let inner = inner_start..inner_start + "*inner*".len();
|
||||
let inner_visible = folded.text.find("inner").unwrap();
|
||||
assert_eq!(folded.reveal_at(inner_visible), Some(inner.clone()));
|
||||
assert_eq!(folded.reveal_at(folded.text.find("outer").unwrap()), Some(outer.clone()));
|
||||
|
||||
let inner_revealed = Projection::with_reveal(source, Some(inner.clone()));
|
||||
assert_eq!(inner_revealed.text, "outer *inner*");
|
||||
assert_eq!(
|
||||
inner_revealed.reveal_at(inner_revealed.text.find("inner").unwrap()),
|
||||
Some(inner)
|
||||
);
|
||||
assert_eq!(inner_revealed.reveal_at(0), Some(outer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_code_offsets_skip_fences_padding_and_preserve_unicode() {
|
||||
for (raw, text) in [
|
||||
("`code`", "code"),
|
||||
("` code `", "code"),
|
||||
("``co`de``", "co`de"),
|
||||
("`` 中😀 ``", "中😀"),
|
||||
] {
|
||||
let content = inline_code_content(raw).unwrap();
|
||||
assert_eq!(inline_code_visible_offset(raw, text, content.start), Some(0));
|
||||
assert_eq!(inline_code_source_offset(raw, text, 0), Some(content.start));
|
||||
assert_eq!(inline_code_visible_offset(raw, text, content.end), Some(text.len()));
|
||||
assert_eq!(inline_code_source_offset(raw, text, text.len()), Some(content.end));
|
||||
}
|
||||
|
||||
let source = "prefix `` 中😀 `` suffix";
|
||||
let projection = Projection::new(source);
|
||||
let leaf = projection.leaves.iter().find(|leaf| leaf.marks.code).unwrap();
|
||||
let content = inline_code_content(&source[leaf.source.clone()]).unwrap();
|
||||
for local in [content.start, content.start + "中".len()] {
|
||||
let source_offset = leaf.source.start + local;
|
||||
let visible_offset = projection.visible_offset(source_offset);
|
||||
assert_eq!(projection.source_offset(visible_offset), source_offset);
|
||||
}
|
||||
assert_eq!(projection.visible_offset(leaf.source.end), leaf.visible.end);
|
||||
// At a shared visible boundary the following leaf owns the caret, so
|
||||
// walking out of code can collapse its delimiters without re-entering it.
|
||||
assert_eq!(projection.visible_offset(leaf.source.start + content.end), leaf.visible.end);
|
||||
assert_eq!(projection.source_offset(leaf.visible.end), leaf.source.end);
|
||||
let trailing = Projection::new("`` 中😀 ``");
|
||||
assert_eq!(trailing.source_offset(trailing.text.len()), "`` 中😀".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_visible_offsets_clamp_escapes_entities_and_unicode() {
|
||||
let source = "a \\*x\\* & 中😀";
|
||||
let projection = Projection::new(source);
|
||||
assert_eq!(projection.text, "a *x* & 中😀");
|
||||
|
||||
let escaped = source.find("\\*").unwrap();
|
||||
let star = projection.text.find('*').unwrap();
|
||||
assert_eq!(projection.visible_offset(escaped), star);
|
||||
assert_eq!(projection.visible_offset(escaped + 1), star);
|
||||
assert_eq!(projection.source_offset(star), escaped);
|
||||
assert_eq!(projection.source_offset(star + 1), escaped + 2);
|
||||
|
||||
let entity = source.find("&").unwrap();
|
||||
let ampersand = projection.text.find('&').unwrap();
|
||||
assert_eq!(projection.visible_offset(entity), ampersand);
|
||||
assert_eq!(projection.visible_offset(entity + 2), ampersand);
|
||||
assert_eq!(projection.source_offset(ampersand), entity);
|
||||
assert_eq!(projection.source_offset(ampersand + 1), entity + "&".len());
|
||||
|
||||
for visible in projection
|
||||
.text
|
||||
.char_indices()
|
||||
.map(|(offset, _)| offset)
|
||||
.chain(std::iter::once(projection.text.len()))
|
||||
{
|
||||
let source_offset = projection.source_offset(visible);
|
||||
assert!(source.is_char_boundary(source_offset));
|
||||
assert_eq!(projection.visible_offset(source_offset), visible);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_and_math_fallbacks_have_local_reveal_ranges() {
|
||||
for source in ["before  after", "before $x^2$ after"] {
|
||||
let projection = Projection::new(source);
|
||||
let marker = if source.contains("" } else { "$x^2$" };
|
||||
let start = source.find(marker).unwrap();
|
||||
let range = start..start + marker.len();
|
||||
let visible = projection.text.find(marker).unwrap();
|
||||
assert_eq!(projection.reveal_at(visible), Some(range.clone()));
|
||||
let revealed = Projection::with_reveal(source, Some(range.clone()));
|
||||
assert_eq!(revealed.text, projection.text);
|
||||
assert_eq!(revealed.revealed(), Some(range));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_reveal_edit_keeps_the_raw_leaf_active() {
|
||||
let source = "before **bold** after";
|
||||
let start = source.find("**bold**").unwrap();
|
||||
let reveal = start..start + "**bold**".len();
|
||||
let mut projection = Projection::with_reveal(source, Some(reveal.clone()));
|
||||
let edited = "before **bold* after";
|
||||
assert!(projection.accept(edited, edited));
|
||||
assert_eq!(projection.text, edited);
|
||||
let edited_range = edited.find("**bold*").unwrap();
|
||||
assert_eq!(projection.revealed(), Some(edited_range..edited_range + "**bold*".len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_revealed_inline_and_its_neighbour_keeps_the_input_projection() {
|
||||
let source = "before **bold** after";
|
||||
let start = source.find("**bold**").unwrap();
|
||||
let reveal = start..start + "**bold**".len();
|
||||
let mut projection = Projection::with_reveal(source, Some(reveal.clone()));
|
||||
assert!(projection.accept("before after", "before after"));
|
||||
assert_eq!(projection.text, "before after");
|
||||
assert_eq!(projection.revealed(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Real GPUI coverage for local Markdown source reveal.
|
||||
//!
|
||||
//! The clicks below use the native input's laid-out ranges. This keeps the
|
||||
//! contract independent of font metrics while exercising the same mouse,
|
||||
//! selection and composition paths as the reader.
|
||||
|
||||
use super::*;
|
||||
use gpui::{EntityInputHandler, Modifiers, MouseButton, TestAppContext, VisualTestContext};
|
||||
use std::ops::Range;
|
||||
|
||||
fn open(
|
||||
source: &str,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (tempfile::TempDir, Entity<TextFileView>, VisualTestContext) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("inline-live.md");
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let (file, window) = tests::open(path, cx);
|
||||
(directory, file, window)
|
||||
}
|
||||
|
||||
fn draw(cx: &mut VisualTestContext) {
|
||||
cx.update(|window, cx| {
|
||||
let _ = window.draw(cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn begin_paragraph(
|
||||
file: &Entity<TextFileView>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Entity<gpui_component::input::InputState> {
|
||||
let input = cx.update(|window, cx| {
|
||||
file.update(cx, |view, cx| {
|
||||
view.live_mode = true;
|
||||
view.begin_live_edit(0, window, cx);
|
||||
view.live_edit.as_ref().unwrap().input.clone()
|
||||
})
|
||||
});
|
||||
draw(cx);
|
||||
input
|
||||
}
|
||||
|
||||
fn input_range_bounds(
|
||||
input: &Entity<gpui_component::input::InputState>,
|
||||
range: &Range<usize>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> gpui::Bounds<gpui::Pixels> {
|
||||
input
|
||||
.read_with(cx, |input, _| input.range_to_bounds(range))
|
||||
.unwrap_or_else(|| panic!("input range has no laid-out bounds: {range:?}"))
|
||||
}
|
||||
|
||||
fn click_input_range(
|
||||
input: &Entity<gpui_component::input::InputState>,
|
||||
range: Range<usize>,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
let bounds = input_range_bounds(input, &range, cx);
|
||||
let point = bounds.center();
|
||||
cx.simulate_mouse_down(point, MouseButton::Left, Modifiers::default());
|
||||
cx.simulate_mouse_up(point, MouseButton::Left, Modifiers::default());
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
let _ = window.draw(cx);
|
||||
window.simulate_next_frame(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn text_range(text: &str, needle: &str) -> Range<usize> {
|
||||
let start = text.find(needle).unwrap_or_else(|| panic!("missing {needle:?} in {text:?}"));
|
||||
start..start + needle.len()
|
||||
}
|
||||
|
||||
fn source_range(source: &str, needle: &str) -> Range<usize> {
|
||||
text_range(source, needle)
|
||||
}
|
||||
|
||||
fn assert_reveal_is_clean(
|
||||
file: &Entity<TextFileView>,
|
||||
input: &Entity<gpui_component::input::InputState>,
|
||||
cx: &mut VisualTestContext,
|
||||
source: &str,
|
||||
reveal: Option<Range<usize>>,
|
||||
rendered: &str,
|
||||
) {
|
||||
file.read_with(cx, |view, cx| {
|
||||
let edit = view.live_edit.as_ref().expect("live input remains active");
|
||||
assert_eq!(edit.input, input.clone(), "revealing an inline must reuse the native input");
|
||||
assert_eq!(edit.projection.revealed(), reveal);
|
||||
assert_eq!(view.draft(cx).as_ref(), source, "reveal must not edit the document");
|
||||
assert!(!view.is_dirty(), "reveal must not mark the document dirty");
|
||||
assert!(!view.preview_stale, "reveal must not schedule a preview parse");
|
||||
if reveal.is_none() {
|
||||
assert_eq!(edit.input.read(cx).value().as_ref(), rendered);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn first_preview_click_reveals_the_hit_inline_without_exposing_its_neighbour(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let source = "**widebold** after _italic_";
|
||||
let (_, file, mut cx) = open(source, cx);
|
||||
draw(&mut cx);
|
||||
let bounds = cx.debug_bounds("markdown-preview-block-0").unwrap();
|
||||
let point = gpui::point(bounds.left() + px(18.0), bounds.top() + px(15.0));
|
||||
cx.simulate_mouse_down(point, MouseButton::Left, Modifiers::default());
|
||||
cx.simulate_mouse_up(point, MouseButton::Left, Modifiers::default());
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| {
|
||||
let _ = window.draw(cx);
|
||||
window.simulate_next_frame(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
file.read_with(&cx, |view, cx| {
|
||||
let edit = view.live_edit.as_ref().unwrap();
|
||||
assert_eq!(edit.input.read(cx).value(), "**widebold** after italic");
|
||||
assert_eq!(edit.projection.revealed(), Some(0..12));
|
||||
assert_eq!(view.draft(cx), source);
|
||||
assert!(!view.dirty);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn clicking_an_inline_reveals_only_that_source_and_plain_text_collapses_it(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let source = "Before **bold** and _italic_ then [link](https://example.test/path \"title\") plus `code` after";
|
||||
let (_directory, file, mut cx) = open(source, cx);
|
||||
let input = begin_paragraph(&file, &mut cx);
|
||||
let rendered = super::inline_edit::Projection::new(source).text;
|
||||
|
||||
for (label, markdown) in [
|
||||
("bold", "**bold**"),
|
||||
("italic", "_italic_"),
|
||||
("link", "[link](https://example.test/path \"title\")"),
|
||||
("code", "`code`"),
|
||||
] {
|
||||
let visible = text_range(&input.read_with(&cx, |input, _| input.value()), label);
|
||||
click_input_range(&input, visible, &mut cx);
|
||||
let expected = source_range(source, markdown);
|
||||
let value = input.read_with(&cx, |input, _| input.value());
|
||||
assert!(value.contains(markdown), "{label} source was not exposed: {value:?}");
|
||||
for (other_label, other_markdown) in [
|
||||
("bold", "**bold**"),
|
||||
("italic", "_italic_"),
|
||||
("link", "[link](https://example.test/path \"title\")"),
|
||||
("code", "`code`"),
|
||||
] {
|
||||
if other_label != label {
|
||||
assert!(
|
||||
!value.contains(other_markdown),
|
||||
"revealing {label} must keep {other_label} rendered: {value:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_reveal_is_clean(&file, &input, &mut cx, source, Some(expected), &rendered);
|
||||
}
|
||||
|
||||
let visible = text_range(&input.read_with(&cx, |input, _| input.value()), "Before");
|
||||
click_input_range(&input, visible, &mut cx);
|
||||
assert_reveal_is_clean(&file, &input, &mut cx, source, None, &rendered);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn link_destination_can_be_saved_and_undone_without_touching_its_label(cx: &mut TestAppContext) {
|
||||
let source = "Before **bold** and [link](https://old.example/path \"title\") after";
|
||||
let (directory, file, mut cx) = open(source, cx);
|
||||
let path = directory.path().join("inline-live.md");
|
||||
let input = begin_paragraph(&file, &mut cx);
|
||||
let link_visible = text_range(&input.read_with(&cx, |input, _| input.value()), "link");
|
||||
click_input_range(&input, link_visible, &mut cx);
|
||||
|
||||
let old_url = "https://old.example/path";
|
||||
let new_url = "https://new.example/path";
|
||||
let url_range = text_range(&input.read_with(&cx, |input, _| input.value()), old_url);
|
||||
input.update(&mut cx, |input, cx| input.set_selected_range(url_range, cx));
|
||||
cx.simulate_input(new_url);
|
||||
cx.run_until_parked();
|
||||
|
||||
let expected = source.replacen(old_url, new_url, 1);
|
||||
assert_eq!(file.read_with(&cx, |view, cx| view.draft(cx).to_string()), expected);
|
||||
assert!(file.read_with(&cx, |view, _| view.is_dirty()));
|
||||
assert_eq!(
|
||||
input.read_with(&cx, |input, _| input.value().to_string()),
|
||||
format!("Before bold and [link]({new_url} \"title\") after")
|
||||
);
|
||||
|
||||
cx.simulate_keystrokes("ctrl-s");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
|
||||
assert!(!file.read_with(&cx, |view, _| view.is_dirty()));
|
||||
|
||||
cx.simulate_keystrokes("ctrl-z");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(file.read_with(&cx, |view, cx| view.draft(cx).to_string()), source);
|
||||
assert!(file.read_with(&cx, |view, _| view.is_dirty()));
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), expected);
|
||||
let resumed = file.read_with(&cx, |view, cx| {
|
||||
view.live_edit
|
||||
.as_ref()
|
||||
.expect("undo keeps the paragraph active")
|
||||
.input
|
||||
.read(cx)
|
||||
.value()
|
||||
.to_string()
|
||||
});
|
||||
let old_rendered = super::inline_edit::Projection::new(source).text;
|
||||
let old_link_revealed = "Before bold and [link](https://old.example/path \"title\") after";
|
||||
assert!(
|
||||
resumed == old_rendered || resumed == old_link_revealed,
|
||||
"undo may restore the link's local projection, but not whole-paragraph source: {resumed:?}"
|
||||
);
|
||||
assert!(!resumed.contains("**bold**"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn cjk_composition_keeps_revealed_inline_and_native_input_identity(cx: &mut TestAppContext) {
|
||||
let source = "前 **你好** 后 [链接](https://example.test) 尾";
|
||||
let (_directory, file, mut cx) = open(source, cx);
|
||||
let input = begin_paragraph(&file, &mut cx);
|
||||
let visible = text_range(&input.read_with(&cx, |input, _| input.value()), "你好");
|
||||
click_input_range(&input, visible, &mut cx);
|
||||
let revealed = source_range(source, "**你好**");
|
||||
let before = file.read_with(&cx, |view, cx| {
|
||||
(
|
||||
view.draft(cx).to_string(),
|
||||
view.is_dirty(),
|
||||
view.preview_stale,
|
||||
view.live_edit.as_ref().unwrap().projection.revealed(),
|
||||
)
|
||||
});
|
||||
assert_eq!(before.0, source);
|
||||
assert!(!before.1 && !before.2);
|
||||
assert_eq!(before.3, Some(revealed.clone()));
|
||||
|
||||
let caret = input.read_with(&cx, |input, _| {
|
||||
let value = input.value();
|
||||
value.find("**你好**").unwrap() + 2 + "你".len()
|
||||
});
|
||||
input.update(&mut cx, |input, cx| input.set_selected_range(caret..caret, cx));
|
||||
let marked_before = cx.update(|window, cx| {
|
||||
input.update(cx, |input, cx| {
|
||||
input.replace_and_mark_text_in_range(None, "世", Some(1..1), window, cx);
|
||||
input.marked_text_range(window, cx)
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let marked_after =
|
||||
cx.update(|window, cx| input.update(cx, |input, cx| input.marked_text_range(window, cx)));
|
||||
assert!(marked_before.is_some(), "composition must create a marked range");
|
||||
assert_eq!(marked_after, marked_before, "selection sync must not rewrite the marked range");
|
||||
file.read_with(&cx, |view, cx| {
|
||||
let edit = view.live_edit.as_ref().expect("composition keeps live editing");
|
||||
assert_eq!(edit.input, input);
|
||||
assert_eq!(
|
||||
edit.projection.revealed(),
|
||||
Some(revealed.start..revealed.end + "世".len()),
|
||||
"composition must extend the same revealed inline"
|
||||
);
|
||||
let value = edit.input.read(cx).value();
|
||||
assert!(value.contains("**你世好**"));
|
||||
assert!(value.contains("链接"), "an untouched neighboring link stays rendered");
|
||||
assert!(!value.contains("[链接]("));
|
||||
assert!(view.draft(cx).contains("**你世好**"));
|
||||
assert!(view.is_dirty(), "composition Change updates the draft");
|
||||
});
|
||||
|
||||
cx.update(|window, cx| input.update(cx, |input, cx| input.unmark_text(window, cx)));
|
||||
cx.run_until_parked();
|
||||
let draft = file.read_with(&cx, |view, cx| view.draft(cx).to_string());
|
||||
assert!(
|
||||
draft.contains("**你世好**"),
|
||||
"composition should commit in the revealed inline: {draft}"
|
||||
);
|
||||
assert!(draft.contains("[链接](https://example.test)"));
|
||||
assert!(file.read_with(&cx, |view, _| view.live_edit.as_ref().unwrap().input == input));
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Hit actual inline object bounds and verify that only their source is edited.
|
||||
|
||||
use super::*;
|
||||
use gpui::{EntityInputHandler, Modifiers, TestAppContext, VisualTestContext};
|
||||
|
||||
fn part_for(
|
||||
file: &Entity<TextFileView>,
|
||||
needle: &str,
|
||||
occurrence: usize,
|
||||
cx: &VisualTestContext,
|
||||
) -> usize {
|
||||
file.read_with(cx, |view, cx| {
|
||||
let source = view.draft(cx);
|
||||
let structure = view.outline.structures[0].as_ref().unwrap();
|
||||
structure
|
||||
.parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, part)| &source[part.range.clone()] == needle)
|
||||
.nth(occurrence)
|
||||
.unwrap_or_else(|| panic!("missing inline {needle}: {structure:?}"))
|
||||
.0
|
||||
})
|
||||
}
|
||||
|
||||
fn draw(cx: &mut VisualTestContext) {
|
||||
cx.update(|window, cx| {
|
||||
let _ = window.draw(cx);
|
||||
window.simulate_next_frame(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn click_part(part: usize, cx: &mut VisualTestContext) {
|
||||
draw(cx);
|
||||
let bounds = cx.debug_bounds(format!("markdown-inline-part-0-{part}").leak()).unwrap();
|
||||
assert!(bounds.size.width > px(0.0) && bounds.size.height > px(0.0), "{bounds:?}");
|
||||
cx.simulate_mouse_down(bounds.center(), MouseButton::Left, Modifiers::default());
|
||||
cx.simulate_mouse_up(bounds.center(), MouseButton::Left, Modifiers::default());
|
||||
cx.run_until_parked();
|
||||
draw(cx);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn second_identical_formula_edits_only_its_source_and_keeps_other_objects(cx: &mut TestAppContext) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("objects.md");
|
||||
let source = "**Before** $x^2$ between $x^2$ **After**";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let (file, mut cx) = tests::open(path.clone(), cx);
|
||||
let first = part_for(&file, "$x^2$", 0, &cx);
|
||||
let second = part_for(&file, "$x^2$", 1, &cx);
|
||||
draw(&mut cx);
|
||||
let first_bounds = cx.debug_bounds(format!("markdown-inline-part-0-{first}").leak()).unwrap();
|
||||
let second_bounds = cx.debug_bounds(format!("markdown-inline-part-0-{second}").leak()).unwrap();
|
||||
assert!(first_bounds.right() <= second_bounds.left(), "inline formulas must share a row");
|
||||
assert!((first_bounds.bottom() - second_bounds.bottom()).abs() < px(3.0));
|
||||
click_part(second, &mut cx);
|
||||
let input = file.read_with(&cx, |view, cx| {
|
||||
let edit = view.live_edit.as_ref().unwrap();
|
||||
assert_eq!(edit.part, Some(second));
|
||||
assert_eq!(edit.input.read(cx).value(), "$x^2$");
|
||||
assert_eq!(edit.range.start, source.rfind("$x^2$").unwrap());
|
||||
assert!(!view.dirty);
|
||||
edit.input.clone()
|
||||
});
|
||||
assert!(cx.debug_bounds("markdown-math-formula").is_some(), "unclicked formula stays rendered");
|
||||
cx.update(|window, cx| {
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_selected_range(1..4, cx);
|
||||
input.replace_and_mark_text_in_range(None, "中", Some(1..1), window, cx);
|
||||
});
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(file.read_with(&cx, |view, _| view.live_edit.as_ref().unwrap().input == input));
|
||||
cx.update(|window, cx| input.update(cx, |input, cx| input.unmark_text(window, cx)));
|
||||
cx.simulate_keystrokes("ctrl-s");
|
||||
cx.run_until_parked();
|
||||
let expected = "**Before** $x^2$ between $中$ **After**";
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), expected);
|
||||
cx.simulate_keystrokes("ctrl-z");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(file.read_with(&cx, |view, cx| view.draft(cx)), source);
|
||||
cx.simulate_keystrokes("ctrl-shift-z");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(file.read_with(&cx, |view, cx| view.draft(cx)), expected);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn image_source_is_local_and_surrounding_formula_stays_rendered(cx: &mut TestAppContext) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
image::RgbaImage::from_pixel(24, 18, image::Rgba([80, 120, 180, 255]))
|
||||
.save(dir.path().join("image.png"))
|
||||
.unwrap();
|
||||
let path = dir.path().join("image.md");
|
||||
let source = "Before  $y^2$ **After**";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let (file, mut cx) = tests::open(path, cx);
|
||||
let image_part = part_for(&file, "", 0, &cx);
|
||||
click_part(image_part, &mut cx);
|
||||
file.read_with(&cx, |view, cx| {
|
||||
let edit = view.live_edit.as_ref().unwrap();
|
||||
assert_eq!(edit.input.read(cx).value(), "");
|
||||
assert_eq!(edit.range, 7..24);
|
||||
assert!(!view.dirty);
|
||||
});
|
||||
assert!(cx.debug_bounds("markdown-math-formula").is_some());
|
||||
cx.simulate_keystrokes("escape");
|
||||
cx.run_until_parked();
|
||||
draw(&mut cx);
|
||||
assert!(file.read_with(&cx, |view, _| view.live_edit.is_none()));
|
||||
assert_eq!(file.read_with(&cx, |view, cx| view.draft(cx)), source);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Adjacent inline views share one paragraph when copied. Their weak handles
|
||||
//! live only while their virtual rows are mounted, independent of edit history.
|
||||
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
impl TextFileView {
|
||||
pub(super) fn inline_selected_text(&self, window: &mut Window, cx: &mut App) -> Option<String> {
|
||||
let mut groups: BTreeMap<usize, BTreeMap<usize, String>> = BTreeMap::new();
|
||||
for (&(block, part), state) in self.inline_views.borrow().iter() {
|
||||
let Some(state) = state.upgrade() else { continue };
|
||||
let selected = state.read(cx).selected_text();
|
||||
if !selected.trim().is_empty() {
|
||||
groups.entry(block).or_default().insert(part, selected);
|
||||
}
|
||||
}
|
||||
if groups.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut native = BTreeMap::new();
|
||||
let mut copied = BTreeMap::new();
|
||||
for (block, selected) in groups {
|
||||
let structure = self.outline.structures.get(block)?.as_ref()?;
|
||||
// A list/table/quote also has structural separators. Leave their
|
||||
// outer selection policy to the existing document renderer.
|
||||
if !matches!(structure.root, super::block_structure::StructureNode::Inline(_)) {
|
||||
return None;
|
||||
}
|
||||
let first = *selected.first_key_value()?.0;
|
||||
let last = *selected.last_key_value()?.0;
|
||||
let range = self.outline.source_ranges.get(block)?;
|
||||
let source = self.source_slice(range.clone(), cx)?;
|
||||
let mut text = String::new();
|
||||
native.insert(block, selected.values().cloned().collect::<Vec<_>>().join("\n"));
|
||||
for part in first..=last {
|
||||
let part_source = source.get(structure.parts.get(part)?.range.clone())?;
|
||||
let rendered = super::inline_edit::Projection::new(part_source).text;
|
||||
// TextView serializes its paragraph with a terminal newline.
|
||||
// That separator is not a boundary between inline fragments;
|
||||
// authored whitespace is restored from the source below.
|
||||
let value = selected
|
||||
.get(&part)
|
||||
.map_or(rendered.as_str(), String::as_str)
|
||||
.trim_end_matches(['\r', '\n']);
|
||||
let leading = part_source.len() - part_source.trim_start().len();
|
||||
let trailing = part_source.len() - part_source.trim_end().len();
|
||||
if (part > first || value == rendered)
|
||||
&& !value.starts_with(&part_source[..leading])
|
||||
{
|
||||
text.push_str(&part_source[..leading]);
|
||||
}
|
||||
text.push_str(value);
|
||||
if (part < last || value == rendered)
|
||||
&& !value.ends_with(&part_source[part_source.len() - trailing..])
|
||||
{
|
||||
text.push_str(&part_source[part_source.len() - trailing..]);
|
||||
}
|
||||
}
|
||||
copied.insert(block, text);
|
||||
}
|
||||
for (index, state) in self.blocks.borrow().iter().enumerate() {
|
||||
let Some(state) = state else { continue };
|
||||
let text = state.read(cx).selected_text();
|
||||
if !text.trim().is_empty() {
|
||||
native.insert(index, text.clone());
|
||||
copied.insert(index, text);
|
||||
}
|
||||
}
|
||||
// Never replace a selection containing another component we do not
|
||||
// own (for example a table cell). This check is only on explicit Copy.
|
||||
let native = native.into_values().collect::<Vec<_>>().join("\n");
|
||||
let global = window.selected_text(cx);
|
||||
if !native.split_whitespace().eq(global.split_whitespace()) {
|
||||
return None;
|
||||
}
|
||||
Some(copied.into_values().collect::<Vec<_>>().join("\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Reader selection across adjacent inline Markdown fragments.
|
||||
//!
|
||||
//! The fragment views remain selectable so the window-level coordinator can
|
||||
//! resolve a drag that crosses their hitboxes. This test intentionally checks
|
||||
//! the rendered reading text, rather than source delimiters: selecting a
|
||||
//! formatted fragment must not activate its live editor.
|
||||
|
||||
use super::*;
|
||||
use gpui::{Modifiers, MouseButton, TestAppContext, VisualTestContext};
|
||||
use gpui_component::WindowExt as _;
|
||||
|
||||
fn open(
|
||||
source: &str,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (tempfile::TempDir, Entity<TextFileView>, VisualTestContext) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("inline-selection.md");
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let (file, window) = tests::open(path, cx);
|
||||
(directory, file, window)
|
||||
}
|
||||
|
||||
fn draw(cx: &mut VisualTestContext) {
|
||||
cx.update(|window, cx| {
|
||||
let _ = window.draw(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn part_containing(
|
||||
file: &Entity<TextFileView>,
|
||||
needle: &str,
|
||||
occurrence: usize,
|
||||
cx: &VisualTestContext,
|
||||
) -> usize {
|
||||
file.read_with(cx, |view, cx| {
|
||||
let source = view.draft(cx);
|
||||
let structure = view.outline.structures[0].as_ref().expect("inline structure");
|
||||
structure
|
||||
.parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, part)| source[part.range.clone()].contains(needle))
|
||||
.nth(occurrence)
|
||||
.unwrap_or_else(|| panic!("missing inline {needle:?} in {source:?}: {structure:?}"))
|
||||
.0
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn dragging_across_inline_fragments_selects_reading_text_without_entering_live_edit(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let source = "Before **bold** $x$ _italic_ after";
|
||||
let (_directory, file, mut cx) = open(source, cx);
|
||||
let first = part_containing(&file, "Before", 0, &cx);
|
||||
let last = part_containing(&file, "after", 0, &cx);
|
||||
|
||||
draw(&mut cx);
|
||||
let first_bounds = cx
|
||||
.debug_bounds(format!("markdown-inline-part-0-{first}").leak())
|
||||
.expect("first inline fragment bounds");
|
||||
let last_bounds = cx
|
||||
.debug_bounds(format!("markdown-inline-part-0-{last}").leak())
|
||||
.expect("last inline fragment bounds");
|
||||
assert!(first_bounds.right() <= last_bounds.left(), "fragments must stay ordered");
|
||||
|
||||
let start = gpui::point(first_bounds.left() + px(0.1), first_bounds.center().y);
|
||||
let end = gpui::point(last_bounds.right() - px(0.1), last_bounds.center().y);
|
||||
cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
|
||||
draw(&mut cx);
|
||||
cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
|
||||
draw(&mut cx);
|
||||
cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
|
||||
draw(&mut cx);
|
||||
|
||||
let selected = cx.update(|window, cx| window.selected_text(cx).to_string());
|
||||
let normalized = selected.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(
|
||||
normalized.contains("Before bold") && normalized.contains("italic after"),
|
||||
"selected: {selected:?}"
|
||||
);
|
||||
assert!(file.read_with(&cx, |view, _| view.live_edit.is_none()));
|
||||
cx.simulate_keystrokes("ctrl-c");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(cx.read_from_clipboard().unwrap().text().unwrap(), "Before bold $x$ italic after");
|
||||
}
|
||||
@@ -46,6 +46,7 @@ impl TextFileView {
|
||||
pub(super) fn apply_input_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(edit) = &self.live_edit else { return };
|
||||
if !matches!(edit.kind, super::block_structure::PartKind::Rich)
|
||||
|| edit.projection.revealed().is_some()
|
||||
|| edit.input.update(cx, |input, cx| input.marked_text_range(window, cx).is_some())
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -59,17 +59,40 @@ impl TextFileView {
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let Some(edit) = &self.live_edit else { return false };
|
||||
if !edit.projection.rich || edit.part.is_some() {
|
||||
let inline_block =
|
||||
self.outline.structures.get(edit.block).and_then(Option::as_ref).is_some_and(
|
||||
|structure| {
|
||||
matches!(structure.root, super::block_structure::StructureNode::Inline(_))
|
||||
},
|
||||
);
|
||||
if !edit.projection.rich || (edit.part.is_some() && !inline_block) {
|
||||
return false;
|
||||
}
|
||||
let selection = edit.input.read(cx).selected_range();
|
||||
let left = edit.projection.replace(&edit.projection.text[..selection.start]);
|
||||
let right =
|
||||
paragraph_source(&edit.projection.replace(&edit.projection.text[selection.end..]));
|
||||
let cursor = edit.range.start + left.len() + 2;
|
||||
let (range, left, right) = if inline_block {
|
||||
let range = self.outline.source_ranges[edit.block].clone();
|
||||
let Some(source) = self.source_slice(range.clone(), cx) else { return false };
|
||||
let offset = edit.range.start - range.start;
|
||||
let reveal =
|
||||
edit.projection.revealed().map(|span| offset + span.start..offset + span.end);
|
||||
let projection = super::inline_edit::Projection::with_reveal(&source, reveal);
|
||||
let start =
|
||||
projection.visible_offset(offset + edit.projection.source_offset(selection.start));
|
||||
let end =
|
||||
projection.visible_offset(offset + edit.projection.source_offset(selection.end));
|
||||
let left = projection.replace(&projection.text[..start]);
|
||||
let right = paragraph_source(&projection.replace(&projection.text[end..]));
|
||||
(range, left, right)
|
||||
} else {
|
||||
let left = edit.projection.replace(&edit.projection.text[..selection.start]);
|
||||
let right =
|
||||
paragraph_source(&edit.projection.replace(&edit.projection.text[selection.end..]));
|
||||
(edit.range.clone(), left, right)
|
||||
};
|
||||
let cursor = range.start + left.len() + 2;
|
||||
let mut source = self.input.read(cx).value().to_string();
|
||||
self.history.record(&source);
|
||||
source.replace_range(edit.range.clone(), &format!("{left}\n\n{right}"));
|
||||
source.replace_range(range, &format!("{left}\n\n{right}"));
|
||||
self.finish_live_edit(cx);
|
||||
self.replace_document_text(&source, window, cx);
|
||||
self.history.record(&source);
|
||||
|
||||
@@ -16,14 +16,15 @@ pub(super) struct LiveEdit {
|
||||
pub(super) projection: Projection,
|
||||
pub(super) part: Option<usize>,
|
||||
pub(super) kind: PartKind,
|
||||
pub(super) last_selection: Range<usize>,
|
||||
changed: bool,
|
||||
heading: Option<u8>,
|
||||
decorations: TextDecorationCollection,
|
||||
pub(super) decorations: TextDecorationCollection,
|
||||
_subscription: Subscription,
|
||||
_observation: Subscription,
|
||||
}
|
||||
|
||||
fn decorations(projection: &Projection, cx: &App) -> Vec<TextDecoration> {
|
||||
pub(super) fn decorations(projection: &Projection, cx: &App) -> Vec<TextDecoration> {
|
||||
projection
|
||||
.marks()
|
||||
.map(|(range, marks)| {
|
||||
@@ -95,6 +96,14 @@ impl TextFileView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(gpui::ClickEvent::Mouse(click)) = click {
|
||||
let movement = click.up.position - click.down.position;
|
||||
if f32::from(movement.x).abs() + f32::from(movement.y).abs() > 4.0 {
|
||||
// A completed reader drag belongs to the shared selection
|
||||
// coordinator; opening an input here would discard that range.
|
||||
return;
|
||||
}
|
||||
}
|
||||
if !self.live_mode
|
||||
|| !self.render_active
|
||||
|| !self.preview
|
||||
@@ -164,6 +173,7 @@ impl TextFileView {
|
||||
InputEvent::Change => {
|
||||
view.update_live_edit(window, cx);
|
||||
view.apply_input_rule(window, cx);
|
||||
view.sync_live_selection(window, cx);
|
||||
},
|
||||
InputEvent::Blur => {
|
||||
view.update_live_edit(window, cx);
|
||||
@@ -175,7 +185,7 @@ impl TextFileView {
|
||||
let observation = cx.observe_in(&input, window, |view, input, window, cx| {
|
||||
if view.live_edit.as_ref().is_some_and(|edit| edit.input == input) {
|
||||
view.update_live_edit(window, cx);
|
||||
cx.notify();
|
||||
view.sync_live_selection(window, cx);
|
||||
}
|
||||
});
|
||||
self.history.barrier();
|
||||
@@ -184,6 +194,7 @@ impl TextFileView {
|
||||
input: input.clone(),
|
||||
range,
|
||||
source,
|
||||
last_selection: projection.text.len()..projection.text.len(),
|
||||
projection,
|
||||
part,
|
||||
kind,
|
||||
@@ -253,10 +264,12 @@ impl TextFileView {
|
||||
|
||||
pub(super) fn update_live_edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(edit) = self.live_edit.as_mut() else { return };
|
||||
let text = edit.input.read(cx).value();
|
||||
if text.as_ref() == edit.projection.text {
|
||||
// Focus, selection and cursor blink notify the same entity. Compare its
|
||||
// Rope before materializing text, so those events allocate no draft.
|
||||
if edit.input.read(cx).text().slice(..) == edit.projection.text.as_str() {
|
||||
return;
|
||||
}
|
||||
let text = edit.input.read(cx).value();
|
||||
let source = self.input.read(cx).text();
|
||||
if !source.try_slice(edit.range.clone()).is_ok_and(|slice| slice == edit.source.as_str()) {
|
||||
self.finish_live_edit(cx);
|
||||
@@ -331,7 +344,11 @@ impl TextFileView {
|
||||
if let Some(edit) = self.live_edit.take() {
|
||||
self.last_edit_cursor = Some(super::activity::EditCursor {
|
||||
anchor: edit.range.start,
|
||||
selection: edit.input.read(cx).selected_range(),
|
||||
selection: {
|
||||
let range = edit.input.read(cx).selected_range();
|
||||
edit.projection.source_offset(range.start)
|
||||
..edit.projection.source_offset(range.end)
|
||||
},
|
||||
});
|
||||
// The just-edited text must be visible during the background parse.
|
||||
if edit.changed
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Inline source visibility follows the native caret. Changing that visibility
|
||||
//! never edits the document, adds undo entries, or replaces the input entity.
|
||||
|
||||
use super::inline_edit::Projection;
|
||||
use super::*;
|
||||
use gpui::EntityInputHandler;
|
||||
|
||||
impl TextFileView {
|
||||
pub(super) fn sync_live_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(edit) = self.live_edit.as_mut() else { return };
|
||||
let input = &edit.input;
|
||||
let selection = input.read(cx).selected_range();
|
||||
if selection == edit.last_selection {
|
||||
return;
|
||||
}
|
||||
// A range selection (including an in-progress mouse drag) owns its
|
||||
// geometry. Do not change its text or its direction under the pointer.
|
||||
if !selection.is_empty()
|
||||
|| !edit.projection.rich
|
||||
|| input.update(cx, |input, cx| input.marked_text_range(window, cx).is_some())
|
||||
{
|
||||
edit.last_selection = selection;
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
let reveal = edit.projection.reveal_at(selection.start);
|
||||
edit.last_selection = selection.clone();
|
||||
if reveal == edit.projection.revealed() {
|
||||
return;
|
||||
}
|
||||
let source_cursor = edit.projection.source_offset(selection.start);
|
||||
let projection = Projection::with_reveal(&edit.source, reveal);
|
||||
let cursor = projection.visible_offset(source_cursor);
|
||||
// This is at most the active bounded block. set_value suppresses Change
|
||||
// events; the canonical source and shared document history stay intact.
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_value(projection.text.clone(), window, cx);
|
||||
input.set_selected_range(cursor..cursor, cx);
|
||||
});
|
||||
edit.last_selection = cursor..cursor;
|
||||
edit.decorations.set(super::live_edit::decorations(&projection, cx), cx);
|
||||
edit.projection = projection;
|
||||
let block = edit.block;
|
||||
self.invalidate_live_block(block);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Editable local text files shared by Markdown and code tabs.
|
||||
|
||||
mod activity;
|
||||
mod block_inline;
|
||||
mod block_structure;
|
||||
mod chrome;
|
||||
mod code_actions;
|
||||
@@ -15,10 +16,18 @@ mod image_cache;
|
||||
mod images;
|
||||
mod info;
|
||||
mod inline_edit;
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod inline_live_tests;
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod inline_object_tests;
|
||||
mod inline_selection;
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod inline_selection_tests;
|
||||
mod input_rules;
|
||||
mod live_commands;
|
||||
mod live_edit;
|
||||
mod live_navigation;
|
||||
mod live_selection;
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod live_tests;
|
||||
mod outline;
|
||||
@@ -27,6 +36,7 @@ mod preview;
|
||||
mod reader_presentation;
|
||||
mod source;
|
||||
mod structure_commands;
|
||||
mod structure_inline_view;
|
||||
#[cfg(all(test, feature = "gpui-test-support"))]
|
||||
mod structure_tests;
|
||||
mod structure_view;
|
||||
@@ -40,9 +50,10 @@ use std::time::Duration;
|
||||
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable, KeyBinding, ListAlignment,
|
||||
ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point,
|
||||
PromptLevel, ScrollHandle, SharedString, Subscription, Task, Window, actions, div, px,
|
||||
App, Bounds, Context, Entity, EntityInputHandler, EventEmitter, FocusHandle, Focusable,
|
||||
KeyBinding, ListAlignment, ListState, MouseButton, MouseDownEvent, MouseMoveEvent,
|
||||
MouseUpEvent, Pixels, Point, PromptLevel, ScrollHandle, SharedString, Subscription, Task,
|
||||
Window, actions, div, px,
|
||||
};
|
||||
use gpui_component::text::TextViewState;
|
||||
|
||||
@@ -114,6 +125,8 @@ pub struct TextFileView {
|
||||
info: bool,
|
||||
outline: Outline,
|
||||
blocks: Rc<RefCell<Vec<Option<Entity<TextViewState>>>>>,
|
||||
inline_views:
|
||||
Rc<RefCell<std::collections::BTreeMap<(usize, usize), gpui::WeakEntity<TextViewState>>>>,
|
||||
preview_extensions: gpui_component::text::MarkdownExtensions,
|
||||
preview_images: Entity<image_cache::DocumentImageCache>,
|
||||
scroll: ListState,
|
||||
@@ -193,11 +206,14 @@ impl TextFileView {
|
||||
});
|
||||
let subscription = cx.subscribe_in(&input, window, |this, _, event, _, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
let source = this.input.read(cx).value();
|
||||
if this.markdown {
|
||||
this.history.record(&source);
|
||||
let input = this.input.read(cx);
|
||||
this.history.record_rope(input.text());
|
||||
}
|
||||
this.dirty = this.document.as_ref().is_some_and(|doc| source.as_ref() != doc.text);
|
||||
this.dirty = this.document.as_ref().is_some_and(|doc| {
|
||||
let input = this.input.read(cx);
|
||||
input.text().slice(0..input.text().len()) != doc.text.as_str()
|
||||
});
|
||||
if this.markdown {
|
||||
this.schedule_preview(cx);
|
||||
}
|
||||
@@ -239,6 +255,7 @@ impl TextFileView {
|
||||
info: false,
|
||||
outline: Outline::default(),
|
||||
blocks: Rc::default(),
|
||||
inline_views: Rc::default(),
|
||||
scroll: ListState::new(0, ListAlignment::Top, px(500.0)),
|
||||
preview_bounds: Rc::default(),
|
||||
preview_selection_scroll_epoch: 0,
|
||||
@@ -535,6 +552,7 @@ impl TextFileView {
|
||||
|
||||
fn apply_outline(&mut self, outline: Outline, cx: &mut Context<Self>) {
|
||||
self.preview_stale = false;
|
||||
self.inline_views.borrow_mut().clear();
|
||||
let top = self.scroll.logical_scroll_top();
|
||||
self.blocks = Rc::new(RefCell::new(vec![None; outline.blocks.len()]));
|
||||
self.scroll.reset(outline.blocks.len().max(usize::from(self.live_mode)));
|
||||
@@ -558,6 +576,7 @@ impl TextFileView {
|
||||
images.set_active(self.render_active && self.preview, window, cx);
|
||||
});
|
||||
if !self.preview {
|
||||
self.inline_views.borrow_mut().clear();
|
||||
self.stop_preview_selection_scroll();
|
||||
for block in self.blocks.borrow_mut().iter_mut() {
|
||||
*block = None;
|
||||
@@ -677,7 +696,14 @@ impl Render for TextFileView {
|
||||
.when(self.markdown, |root| {
|
||||
root.capture_action(cx.listener(
|
||||
|view, _: &gpui_component::input::Undo, window, cx| {
|
||||
if view.document_input_focused(window, cx) {
|
||||
let composing = view.live_edit.as_ref().is_some_and(|edit| {
|
||||
edit.input.update(cx, |input, cx| {
|
||||
input.marked_text_range(window, cx).is_some()
|
||||
})
|
||||
});
|
||||
if composing {
|
||||
cx.propagate();
|
||||
} else if view.document_input_focused(window, cx) {
|
||||
view.travel_history(false, window, cx);
|
||||
cx.stop_propagation();
|
||||
} else {
|
||||
@@ -686,7 +712,13 @@ impl Render for TextFileView {
|
||||
},
|
||||
))
|
||||
.capture_action(cx.listener(|view, _: &gpui_component::input::Redo, window, cx| {
|
||||
if view.document_input_focused(window, cx) {
|
||||
let composing = view.live_edit.as_ref().is_some_and(|edit| {
|
||||
edit.input
|
||||
.update(cx, |input, cx| input.marked_text_range(window, cx).is_some())
|
||||
});
|
||||
if composing {
|
||||
cx.propagate();
|
||||
} else if view.document_input_focused(window, cx) {
|
||||
view.travel_history(true, window, cx);
|
||||
cx.stop_propagation();
|
||||
} else {
|
||||
@@ -695,7 +727,14 @@ impl Render for TextFileView {
|
||||
}))
|
||||
.capture_action(cx.listener(
|
||||
|view, _: &gpui_component::input::Escape, window, cx| {
|
||||
if view.live_edit.is_some() {
|
||||
let composing = view.live_edit.as_ref().is_some_and(|edit| {
|
||||
edit.input.update(cx, |input, cx| {
|
||||
input.marked_text_range(window, cx).is_some()
|
||||
})
|
||||
});
|
||||
if composing {
|
||||
cx.propagate();
|
||||
} else if view.live_edit.is_some() {
|
||||
view.finish_live_edit(cx);
|
||||
view.focus.focus(window, cx);
|
||||
cx.stop_propagation();
|
||||
@@ -706,7 +745,14 @@ impl Render for TextFileView {
|
||||
))
|
||||
.capture_action(cx.listener(
|
||||
|view, action: &gpui_component::input::Enter, window, cx| {
|
||||
if action.secondary && view.live_edit.is_some() {
|
||||
let composing = view.live_edit.as_ref().is_some_and(|edit| {
|
||||
edit.input.update(cx, |input, cx| {
|
||||
input.marked_text_range(window, cx).is_some()
|
||||
})
|
||||
});
|
||||
if composing {
|
||||
cx.propagate();
|
||||
} else if action.secondary && view.live_edit.is_some() {
|
||||
view.finish_live_edit(cx);
|
||||
view.focus.focus(window, cx);
|
||||
cx.stop_propagation();
|
||||
@@ -809,8 +855,21 @@ impl Render for TextFileView {
|
||||
))
|
||||
})
|
||||
.on_action(cx.listener(|this, _: &FinishBlockEdit, window, cx| {
|
||||
this.finish_live_edit(cx);
|
||||
this.focus.focus(window, cx);
|
||||
let composing = this.live_edit.as_ref().is_some_and(|edit| {
|
||||
edit.input.update(cx, |input, cx| input.marked_text_range(window, cx).is_some())
|
||||
});
|
||||
if composing {
|
||||
// Escape/Ctrl-Enter can resolve to the editor's finish
|
||||
// action before the input's own Escape handler. Keep the
|
||||
// native entity alive while the IME composition settles.
|
||||
if let Some(edit) = &this.live_edit {
|
||||
edit.input.update(cx, |input, cx| input.unmark_text(window, cx));
|
||||
}
|
||||
cx.notify();
|
||||
} else {
|
||||
this.finish_live_edit(cx);
|
||||
this.focus.focus(window, cx);
|
||||
}
|
||||
}))
|
||||
.on_action(cx.listener(|view, _: &BoldSelection, window, cx| {
|
||||
view.format_live_selection("**", window, cx);
|
||||
@@ -829,12 +888,15 @@ impl Render for TextFileView {
|
||||
cx.notify();
|
||||
},
|
||||
))
|
||||
.capture_action(cx.listener(|this, _: &gpui_component::input::Copy, _, cx| {
|
||||
.capture_action(cx.listener(|this, _: &gpui_component::input::Copy, window, cx| {
|
||||
if this.all_selected {
|
||||
// Do not materialize off-screen preview blocks for copy.
|
||||
let text = this.input.read(cx).value().to_string();
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
|
||||
cx.stop_propagation();
|
||||
} else if let Some(text) = this.inline_selected_text(window, cx) {
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
|
||||
cx.stop_propagation();
|
||||
} else {
|
||||
cx.propagate();
|
||||
}
|
||||
|
||||
@@ -62,8 +62,16 @@ impl Outline {
|
||||
let mut outline = Self::parse(source);
|
||||
// Rendering may substitute cached still images, but editing always uses
|
||||
// byte ranges in the original source, never the rewritten preview text.
|
||||
for block in &mut outline.blocks {
|
||||
*block = super::images::rewrite_doc_images(block, base);
|
||||
for (block, structure) in outline.blocks.iter_mut().zip(&mut outline.structures) {
|
||||
if let Some(structure) = structure {
|
||||
for part in &mut std::sync::Arc::make_mut(structure).parts {
|
||||
let source = &block[part.range.clone()];
|
||||
let preview = super::images::rewrite_doc_images(source, base);
|
||||
part.preview = (preview != source).then_some(preview);
|
||||
}
|
||||
} else {
|
||||
*block = super::images::rewrite_doc_images(block, base);
|
||||
}
|
||||
}
|
||||
for (definition, _) in outline.definitions.values_mut() {
|
||||
*definition = super::images::rewrite_doc_images(definition, base);
|
||||
|
||||
@@ -298,6 +298,7 @@ impl TextFileView {
|
||||
let frame_blocks = Rc::new(RefCell::new(std::collections::HashSet::new()));
|
||||
let rendered_blocks = frame_blocks.clone();
|
||||
let retained_blocks = self.blocks.clone();
|
||||
let inline_views = self.inline_views.clone();
|
||||
let owner = cx.entity().downgrade();
|
||||
let extensions = self.preview_extensions.clone();
|
||||
let scroll = self.scroll.clone();
|
||||
@@ -463,6 +464,7 @@ impl TextFileView {
|
||||
// mounted its visible and overscan items. The active input
|
||||
// is document-owned and survives this eviction.
|
||||
let mounted = std::mem::take(&mut *frame_blocks.borrow_mut());
|
||||
inline_views.borrow_mut().retain(|(block, _), _| mounted.contains(block));
|
||||
if !mounted.is_empty() {
|
||||
for (index, slot) in retained_blocks.borrow_mut().iter_mut().enumerate()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Native editing of one run in a paragraph containing embedded objects. The
|
||||
//! remaining runs keep their normal text, image and formula presentation.
|
||||
|
||||
use super::block_structure::{BlockStructure, InlineRun, PartKind};
|
||||
use super::*;
|
||||
use gpui_component::text::{TextView, TextViewStyle};
|
||||
|
||||
impl TextFileView {
|
||||
pub(super) fn render_inline_parts(
|
||||
&self,
|
||||
runs: &[InlineRun],
|
||||
structure: &BlockStructure,
|
||||
source: &str,
|
||||
block: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> gpui::AnyElement {
|
||||
let mut line = h_flex().w_full().min_w_0().flex_wrap().items_end();
|
||||
for run in runs {
|
||||
let part = run.part;
|
||||
let spec = &structure.parts[part];
|
||||
let active = self
|
||||
.live_edit
|
||||
.as_ref()
|
||||
.filter(|edit| edit.block == block && edit.part == Some(part));
|
||||
let mut frame = div()
|
||||
.id(("inline-edit-part", part))
|
||||
.debug_selector(move || format!("markdown-inline-part-{block}-{part}"))
|
||||
.min_w(px(2.0))
|
||||
.max_w_full()
|
||||
.flex_shrink_0()
|
||||
.cursor_text()
|
||||
.text_size(px(reader_presentation::heading_size(spec.heading)))
|
||||
.when(spec.heading.is_some(), |frame| frame.font_semibold())
|
||||
.when(run.marks.bold, |frame| frame.font_bold())
|
||||
.when(run.marks.italic, |frame| frame.italic())
|
||||
.when(run.marks.link, |frame| frame.text_color(cx.theme().link))
|
||||
.on_click(cx.listener(move |view, event, window, cx| {
|
||||
view.begin_live_part_at(block, Some(part), Some(event), window, cx);
|
||||
cx.stop_propagation();
|
||||
}));
|
||||
if let Some(edit) = active {
|
||||
self.inline_views.borrow_mut().remove(&(block, part));
|
||||
// Only the active run needs an input. Its unwrapped glyph width
|
||||
// lets short formulas remain inline; the parent caps long edits
|
||||
// at the reading column and the native input soft-wraps them.
|
||||
let mut style = window.text_style();
|
||||
style.font_family = if matches!(spec.kind, PartKind::Source) {
|
||||
cx.theme().mono_font_family.clone()
|
||||
} else {
|
||||
cx.theme().font_family.clone()
|
||||
};
|
||||
if run.marks.bold {
|
||||
style.font_weight = gpui::FontWeight::BOLD;
|
||||
}
|
||||
if run.marks.italic {
|
||||
style.font_style = gpui::FontStyle::Italic;
|
||||
}
|
||||
let font_size = px(if matches!(spec.kind, PartKind::Source) {
|
||||
13.0
|
||||
} else {
|
||||
reader_presentation::heading_size(spec.heading)
|
||||
});
|
||||
let width = edit
|
||||
.projection
|
||||
.text
|
||||
.lines()
|
||||
.map(|text| {
|
||||
window
|
||||
.text_system()
|
||||
.shape_line(
|
||||
text.to_owned().into(),
|
||||
font_size,
|
||||
&[style.to_run(text.len())],
|
||||
None,
|
||||
)
|
||||
.width
|
||||
})
|
||||
.fold(px(0.0), |width, next| width.max(next));
|
||||
frame = frame.w((width + px(12.0)).max(px(40.0))).child(self.render_live_input(cx));
|
||||
} else {
|
||||
let raw = &source[spec.range.clone()];
|
||||
let text = self.outline.part_source(block, spec.preview.as_deref().unwrap_or(raw));
|
||||
let owner = cx.weak_entity();
|
||||
let state = window.use_keyed_state(
|
||||
gpui::SharedString::from(format!("inline-state-{block}-{part}")),
|
||||
cx,
|
||||
|_, cx| TextViewState::markdown(&text, cx),
|
||||
);
|
||||
state.update(cx, |state, cx| state.set_text(&text, cx));
|
||||
self.inline_views.borrow_mut().insert((block, part), state.downgrade());
|
||||
// A fragment's Markdown parser owns only its content. Spaces
|
||||
// between a text run and an object still belong to the outer
|
||||
// paragraph, including when a run contains only whitespace.
|
||||
let leading = raw.len() - raw.trim_start_matches([' ', '\t', '\n', '\r']).len();
|
||||
let trailing = raw.trim_start_matches([' ', '\t', '\n', '\r']).len()
|
||||
- raw.trim_matches([' ', '\t', '\n', '\r']).len();
|
||||
let style = window.text_style();
|
||||
let space = window
|
||||
.text_system()
|
||||
.shape_line(
|
||||
" ".into(),
|
||||
px(reader_presentation::heading_size(spec.heading)),
|
||||
&[style.to_run(1)],
|
||||
None,
|
||||
)
|
||||
.width;
|
||||
frame = frame.pl(space * leading as f32).pr(space * trailing as f32);
|
||||
frame = frame.child(
|
||||
TextView::new(&state)
|
||||
.min_w_0()
|
||||
.max_w_full()
|
||||
.selectable(true)
|
||||
.scrollable(false)
|
||||
.style(TextViewStyle {
|
||||
paragraph_gap: gpui::rems(0.0),
|
||||
image_base: self.path.parent().map(std::sync::Arc::from),
|
||||
highlight_theme: cx.theme().highlight_theme.clone(),
|
||||
is_dark: cx.theme().is_dark(),
|
||||
inline_code: gpui::HighlightStyle {
|
||||
background_color: Some(super::super::theme::code_block_background(
|
||||
cx,
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.on_link_click(move |url, event, window, cx| {
|
||||
if event.modifiers().control || event.modifiers().platform {
|
||||
cx.open_url(url);
|
||||
} else {
|
||||
let _ = owner.update(cx, |view, cx| {
|
||||
view.begin_live_part_at(
|
||||
block,
|
||||
Some(part),
|
||||
Some(event),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
.markdown_extensions(self.preview_extensions.clone()),
|
||||
);
|
||||
}
|
||||
line = line.child(frame);
|
||||
}
|
||||
line.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,7 @@ fn inline_formula_itself_can_be_clicked_to_reveal_source(cx: &mut TestAppContext
|
||||
click("markdown-math-formula", &mut cx);
|
||||
assert_eq!(
|
||||
file.read_with(&cx, |view, cx| view.live_edit.as_ref().unwrap().input.read(cx).value()),
|
||||
source
|
||||
"$x^2 + y^2$"
|
||||
);
|
||||
assert!(!file.read_with(&cx, |view, _| view.dirty));
|
||||
}
|
||||
|
||||
@@ -29,6 +29,34 @@ impl TextFileView {
|
||||
cx: &mut Context<Self>,
|
||||
) -> gpui::AnyElement {
|
||||
match node {
|
||||
StructureNode::Inline(runs) => {
|
||||
self.render_inline_parts(runs, structure, source, block, window, cx)
|
||||
},
|
||||
StructureNode::Literal(part) => {
|
||||
let part = *part;
|
||||
let active = self
|
||||
.live_edit
|
||||
.as_ref()
|
||||
.is_some_and(|edit| edit.block == block && edit.part == Some(part));
|
||||
div()
|
||||
.id(("literal-edit-part", part))
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.cursor_text()
|
||||
.on_click(cx.listener(move |view, event, window, cx| {
|
||||
view.begin_live_part_at(block, Some(part), Some(event), window, cx);
|
||||
cx.stop_propagation();
|
||||
}))
|
||||
.child(if active {
|
||||
self.render_live_input(cx)
|
||||
} else {
|
||||
div()
|
||||
.whitespace_normal()
|
||||
.child(source[structure.parts[part].range.clone()].to_owned())
|
||||
.into_any_element()
|
||||
})
|
||||
.into_any_element()
|
||||
},
|
||||
StructureNode::Text(part) => {
|
||||
self.render_edit_part(*part, structure, source, block, window, cx)
|
||||
},
|
||||
@@ -247,8 +275,10 @@ impl TextFileView {
|
||||
let content = if active {
|
||||
self.render_live_input(cx)
|
||||
} else {
|
||||
let text =
|
||||
self.outline.part_source(block, &source[structure.parts[part].range.clone()]);
|
||||
let spec = &structure.parts[part];
|
||||
let text = self
|
||||
.outline
|
||||
.part_source(block, spec.preview.as_deref().unwrap_or(&source[spec.range.clone()]));
|
||||
let state = window.use_keyed_state(
|
||||
gpui::SharedString::from(format!("document-part-{block}-{part}")),
|
||||
cx,
|
||||
|
||||
Reference in new issue
Block a user