Merge branch 'main' into fix/git-drawer-repaint-20260920

This commit is contained in:
Kuddev authored and GitHub committed 2026-09-20 20:06:42 +08:00
commit 1e077a0d6f
20 files changed
+1868 -62

No files matched your search

@@ -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** ![pic](a.png) $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()] == "![pic](a.png)"));
}
#[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 ![alt](tiny.gif) 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()] == "![alt](tiny.gif)")
.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\\* &amp; 中😀";
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("&amp;").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 + "&amp;".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 ![alt](image.png) after", "before $x^2$ after"] {
let projection = Projection::new(source);
let marker = if source.contains("![") { "![alt](image.png)" } 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 ![alt](image.png) $y^2$ **After**";
std::fs::write(&path, source).unwrap();
let (file, mut cx) = tests::open(path, cx);
let image_part = part_for(&file, "![alt](image.png)", 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(), "![alt](image.png)");
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();
}
}
+75 -13
View File
@@ -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,