diff --git a/architecture/notes/nebula_app/gpui_shell/file_editor/2026-09-20-inline-source-reveal.md b/architecture/notes/nebula_app/gpui_shell/file_editor/2026-09-20-inline-source-reveal.md new file mode 100644 index 0000000..f7a44e9 --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/file_editor/2026-09-20-inline-source-reveal.md @@ -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. diff --git a/nebula_app/src/gpui_shell/file_editor/activity.rs b/nebula_app/src/gpui_shell/file_editor/activity.rs index a371162..510e041 100644 --- a/nebula_app/src/gpui_shell/file_editor/activity.rs +++ b/nebula_app/src/gpui_shell/file_editor/activity.rs @@ -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)); } } diff --git a/nebula_app/src/gpui_shell/file_editor/block_inline.rs b/nebula_app/src/gpui_shell/file_editor/block_inline.rs new file mode 100644 index 0000000..0c159e1 --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/block_inline.rs @@ -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, +) -> Option { + 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, + runs: Vec, + heading: Option, +} + +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); + } +} diff --git a/nebula_app/src/gpui_shell/file_editor/block_structure.rs b/nebula_app/src/gpui_shell/file_editor/block_structure.rs index b2a2faf..6149bc7 100644 --- a/nebula_app/src/gpui_shell/file_editor/block_structure.rs +++ b/nebula_app/src/gpui_shell/file_editor/block_structure.rs @@ -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, pub kind: PartKind, pub heading: Option, + pub preview: Option, } #[derive(Clone, Debug)] @@ -31,6 +33,8 @@ pub(super) struct ListItem { #[derive(Clone, Debug)] pub(super) enum StructureNode { Text(usize), + Inline(Vec), + Literal(usize), Code { part: usize, span: Range, language: String }, Math { part: usize, span: Range }, List(Vec), @@ -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, 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, old: &Range, delta: isize) { } } -fn span(node: &Node, offset: usize) -> Option> { +pub(super) fn span(node: &Node, offset: usize) -> Option> { 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, range: Range, kind: PartKind, heading: Option, ) -> 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, diff --git a/nebula_app/src/gpui_shell/file_editor/edit_history.rs b/nebula_app/src/gpui_shell/file_editor/edit_history.rs index 26d295f..b375f2a 100644 --- a/nebula_app/src/gpui_shell/file_editor/edit_history.rs +++ b/nebula_app/src/gpui_shell/file_editor/edit_history.rs @@ -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, Range) { + 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::(); 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"); + } } diff --git a/nebula_app/src/gpui_shell/file_editor/inline_edit.rs b/nebula_app/src/gpui_shell/file_editor/inline_edit.rs index 76922ee..d6b2053 100644 --- a/nebula_app/src/gpui_shell/file_editor/inline_edit.rs +++ b/nebula_app/src/gpui_shell/file_editor/inline_edit.rs @@ -20,6 +20,13 @@ struct Leaf { text: String, marks: Marks, raw: bool, + revealable: bool, +} + +#[derive(Clone)] +struct Expandable { + source: Range, + visible: Range, } pub(super) struct Projection { @@ -27,6 +34,8 @@ pub(super) struct Projection { pub text: String, leaves: Vec, containers: Vec>, + expandables: Vec, + reveal_range: Option>, pub rich: bool, literal: bool, } @@ -49,6 +58,39 @@ pub(super) fn changed_span(before: &str, after: &str) -> (Range, Range, + changed: &Range, + inserted: usize, + source_len: usize, +) -> Range { + 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, change: &Range, 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>) -> 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(), "
" | "
" | "
") => { - 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, text: String, marks: Marks, raw: bool) { + fn push( + &mut self, + source: Range, + 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) -> Option> { + let mut visible: Option> = 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) -> Option> { + self.expandables + .iter() + .find(|expandable| expandable.source == source) + .map(|expandable| expandable.source.clone()) + } + + fn apply_reveal(&mut self, source: Range) { + 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, 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> { + 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> { + 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 { 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 { 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 { (visible == offset).then_some(source) } +fn inline_code_content(raw: &str) -> Option> { + 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 { + 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 { + 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 { + 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 ![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); + } } diff --git a/nebula_app/src/gpui_shell/file_editor/inline_live_tests.rs b/nebula_app/src/gpui_shell/file_editor/inline_live_tests.rs new file mode 100644 index 0000000..c2c2d10 --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/inline_live_tests.rs @@ -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, 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, + cx: &mut VisualTestContext, +) -> Entity { + 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, + range: &Range, + cx: &mut VisualTestContext, +) -> gpui::Bounds { + 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, + range: Range, + 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 { + 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 { + text_range(source, needle) +} + +fn assert_reveal_is_clean( + file: &Entity, + input: &Entity, + cx: &mut VisualTestContext, + source: &str, + reveal: Option>, + 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)); +} diff --git a/nebula_app/src/gpui_shell/file_editor/inline_object_tests.rs b/nebula_app/src/gpui_shell/file_editor/inline_object_tests.rs new file mode 100644 index 0000000..41e80d0 --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/inline_object_tests.rs @@ -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, + 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); +} diff --git a/nebula_app/src/gpui_shell/file_editor/inline_selection.rs b/nebula_app/src/gpui_shell/file_editor/inline_selection.rs new file mode 100644 index 0000000..2dacc8b --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/inline_selection.rs @@ -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 { + let mut groups: BTreeMap> = 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::>().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::>().join("\n"); + let global = window.selected_text(cx); + if !native.split_whitespace().eq(global.split_whitespace()) { + return None; + } + Some(copied.into_values().collect::>().join("\n")) + } +} diff --git a/nebula_app/src/gpui_shell/file_editor/inline_selection_tests.rs b/nebula_app/src/gpui_shell/file_editor/inline_selection_tests.rs new file mode 100644 index 0000000..76de3ff --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/inline_selection_tests.rs @@ -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, 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, + 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::>().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"); +} diff --git a/nebula_app/src/gpui_shell/file_editor/input_rules.rs b/nebula_app/src/gpui_shell/file_editor/input_rules.rs index bfcae15..c1238cb 100644 --- a/nebula_app/src/gpui_shell/file_editor/input_rules.rs +++ b/nebula_app/src/gpui_shell/file_editor/input_rules.rs @@ -46,6 +46,7 @@ impl TextFileView { pub(super) fn apply_input_rule(&mut self, window: &mut Window, cx: &mut Context) { 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; diff --git a/nebula_app/src/gpui_shell/file_editor/live_commands.rs b/nebula_app/src/gpui_shell/file_editor/live_commands.rs index 9329379..fc241f4 100644 --- a/nebula_app/src/gpui_shell/file_editor/live_commands.rs +++ b/nebula_app/src/gpui_shell/file_editor/live_commands.rs @@ -59,17 +59,40 @@ impl TextFileView { cx: &mut Context, ) -> 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); diff --git a/nebula_app/src/gpui_shell/file_editor/live_edit.rs b/nebula_app/src/gpui_shell/file_editor/live_edit.rs index 9ab6faf..49154d1 100644 --- a/nebula_app/src/gpui_shell/file_editor/live_edit.rs +++ b/nebula_app/src/gpui_shell/file_editor/live_edit.rs @@ -16,14 +16,15 @@ pub(super) struct LiveEdit { pub(super) projection: Projection, pub(super) part: Option, pub(super) kind: PartKind, + pub(super) last_selection: Range, changed: bool, heading: Option, - decorations: TextDecorationCollection, + pub(super) decorations: TextDecorationCollection, _subscription: Subscription, _observation: Subscription, } -fn decorations(projection: &Projection, cx: &App) -> Vec { +pub(super) fn decorations(projection: &Projection, cx: &App) -> Vec { projection .marks() .map(|(range, marks)| { @@ -95,6 +96,14 @@ impl TextFileView { window: &mut Window, cx: &mut Context, ) { + 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) { 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 diff --git a/nebula_app/src/gpui_shell/file_editor/live_selection.rs b/nebula_app/src/gpui_shell/file_editor/live_selection.rs new file mode 100644 index 0000000..326a20b --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/live_selection.rs @@ -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) { + 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(); + } +} diff --git a/nebula_app/src/gpui_shell/file_editor/mod.rs b/nebula_app/src/gpui_shell/file_editor/mod.rs index e269a76..5ee544b 100644 --- a/nebula_app/src/gpui_shell/file_editor/mod.rs +++ b/nebula_app/src/gpui_shell/file_editor/mod.rs @@ -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>>>>, + inline_views: + Rc>>>, preview_extensions: gpui_component::text::MarkdownExtensions, preview_images: Entity, 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.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(); } diff --git a/nebula_app/src/gpui_shell/file_editor/outline.rs b/nebula_app/src/gpui_shell/file_editor/outline.rs index 4d9e62b..012363d 100644 --- a/nebula_app/src/gpui_shell/file_editor/outline.rs +++ b/nebula_app/src/gpui_shell/file_editor/outline.rs @@ -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); diff --git a/nebula_app/src/gpui_shell/file_editor/preview.rs b/nebula_app/src/gpui_shell/file_editor/preview.rs index 05af4c0..8083e9f 100644 --- a/nebula_app/src/gpui_shell/file_editor/preview.rs +++ b/nebula_app/src/gpui_shell/file_editor/preview.rs @@ -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() { diff --git a/nebula_app/src/gpui_shell/file_editor/structure_inline_view.rs b/nebula_app/src/gpui_shell/file_editor/structure_inline_view.rs new file mode 100644 index 0000000..317bfd6 --- /dev/null +++ b/nebula_app/src/gpui_shell/file_editor/structure_inline_view.rs @@ -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, + ) -> 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() + } +} diff --git a/nebula_app/src/gpui_shell/file_editor/structure_tests.rs b/nebula_app/src/gpui_shell/file_editor/structure_tests.rs index 9462004..fbdae1c 100644 --- a/nebula_app/src/gpui_shell/file_editor/structure_tests.rs +++ b/nebula_app/src/gpui_shell/file_editor/structure_tests.rs @@ -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)); } diff --git a/nebula_app/src/gpui_shell/file_editor/structure_view.rs b/nebula_app/src/gpui_shell/file_editor/structure_view.rs index 7a0f811..b62f2ee 100644 --- a/nebula_app/src/gpui_shell/file_editor/structure_view.rs +++ b/nebula_app/src/gpui_shell/file_editor/structure_view.rs @@ -29,6 +29,34 @@ impl TextFileView { cx: &mut Context, ) -> 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,