From af512109b2924799b6e70d0aac16c4f516716a21 Mon Sep 17 00:00:00 2001 From: white07S Date: Thu, 24 Sep 2026 00:07:11 +0200 Subject: [PATCH 1/6] text_view: Add range highlights Let applications highlight ranges of the rendered text, e.g. search results, without reparsing the document. Refs #3214 --- crates/base/src/lib.rs | 5 +- crates/base/src/text/inline.rs | 341 +++++++++++++- crates/base/src/text/inline_flow.rs | 10 + crates/base/src/text/mod.rs | 2 + crates/base/src/text/node.rs | 98 ++-- crates/base/src/text/range_highlight.rs | 592 ++++++++++++++++++++++++ crates/base/src/text/state.rs | 592 +++++++++++++++++++++++- crates/base/src/text/stream_fade.rs | 28 +- crates/component/src/text/mod.rs | 5 +- examples/markdown/src/main.rs | 77 ++- website/base/text-view.md | 6 + website/component/text-view.md | 62 +++ website/zh-CN/base/text-view.md | 2 + website/zh-CN/component/text-view.md | 37 ++ 14 files changed, 1808 insertions(+), 49 deletions(-) create mode 100644 crates/base/src/text/range_highlight.rs diff --git a/crates/base/src/lib.rs b/crates/base/src/lib.rs index ae7296f87a..7cfecf870c 100644 --- a/crates/base/src/lib.rs +++ b/crates/base/src/lib.rs @@ -175,8 +175,9 @@ pub use table::{Table, TableBody, TableCaption, TableCell, TableHead, TableHeade pub use tabs::{Tab, TabStyles, Tabs}; pub use text::{ InlineElement, InlineRenderContext, MarkdownExtensions, MarkdownNode, MarkdownParseContext, - MarkdownPlugin, SelectionFormat, TableData, Text, TextView, TextViewDefaults, TextViewMotion, - TextViewPlugin, TextViewState, TextViewStyle, html, markdown, markdown_ast, + MarkdownPlugin, RangeHighlight, RangeHighlightError, RenderedText, SelectionFormat, TableData, + Text, TextView, TextViewDefaults, TextViewMotion, TextViewPlugin, TextViewState, TextViewStyle, + html, markdown, markdown_ast, }; pub use text_selection::{ TextSelection, TextSelectionContentKey, TextSelectionCoverage, TextSelectionEndpoint, diff --git a/crates/base/src/text/inline.rs b/crates/base/src/text/inline.rs index d0ba0cd03c..c1ff12bf75 100644 --- a/crates/base/src/text/inline.rs +++ b/crates/base/src/text/inline.rs @@ -10,9 +10,10 @@ use std::{ use gpui::{ App, BorderStyle, Bounds, ClickEvent, CursorStyle, Edges, Element, ElementId, GlobalElementId, - Half, HighlightStyle, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, + Half, HighlightStyle, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, LayoutId, MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, - SharedString, StyledText, TextLayout, TextRun, TextStyle, Window, point, px, quad, size, + SharedString, StyledText, TextAlign, TextLayout, TextRun, TextStyle, Window, point, px, quad, + size, }; use crate::{ @@ -203,6 +204,8 @@ pub(super) struct Inline { paint_origin: Option>, selection_bounds: Option>, selection_source: Option<(Arc>, Range)>, + /// Range highlight backgrounds, painted behind the text. + range_backgrounds: Vec<(Range, Hsla)>, link_click_handler: Option>, /// What this frame's layout was shaped with, to hand the shaped text to /// the next frame (see [`RetainedLayout`]). @@ -363,6 +366,7 @@ impl Inline { paint_origin: None, selection_bounds: None, selection_source: None, + range_backgrounds: Vec::new(), link_click_handler, retained_key: None, handed_over: false, @@ -396,6 +400,13 @@ impl Inline { self } + /// Paint `backgrounds` behind the text. They are not part of the text + /// runs, so changing them does not shape the text again. + pub(super) fn range_backgrounds(mut self, backgrounds: Vec<(Range, Hsla)>) -> Self { + self.range_backgrounds = backgrounds; + self + } + /// Get link at given mouse position. fn link_for_position( layout: &TextLayout, @@ -710,6 +721,28 @@ impl Inline { )); } } + + /// Paint each range highlight behind the text of its range. + fn paint_range_highlights(&self, text_layout: &TextLayout, window: &mut Window) { + let glyphs = glyph_boxes( + text_layout, + window.text_style().text_align, + text_layout.bounds().size.width, + ); + let origin = text_layout.bounds().origin; + let line_height = text_layout.line_height(); + for (range, color) in &self.range_backgrounds { + for (row, left, right) in range_boxes(&glyphs, range.clone()) { + window.paint_quad(gpui::fill( + Bounds::from_corners( + point(origin.x + left, origin.y + line_height * row as f32), + point(origin.x + right, origin.y + line_height * (row + 1) as f32), + ), + *color, + )); + } + } + } } impl IntoElement for Inline { @@ -818,6 +851,9 @@ impl Element for Inline { return; } let text_layout = self.styled_text.layout().clone(); + if !self.range_backgrounds.is_empty() { + self.paint_range_highlights(&text_layout, window); + } self.styled_text .paint(global_id, None, bounds, &mut (), &mut (), window, cx); @@ -1029,6 +1065,132 @@ impl Element for Inline { } } +/// Where one glyph of laid-out text paints: its row, and its horizontal +/// extent past the text's origin, alignment applied. `text` is the byte range +/// of the text it draws. +#[derive(Clone, Debug, PartialEq)] +struct GlyphBox { + text: Range, + row: usize, + left: Pixels, + right: Pixels, +} + +/// The glyphs of `text_layout`, sorted by the text they draw, each placed +/// the way GPUI paints it: every row aligned in `align_width` by `align`, +/// and a glyph reaching to the next one on its row, or to the row's end. +/// +/// Glyphs are read in the order they paint, so right-to-left text, whose +/// glyphs paint in the reverse order of its text, is placed as it shows. +fn glyph_boxes(text_layout: &TextLayout, align: TextAlign, align_width: Pixels) -> Vec { + let mut boxes = Vec::new(); + let mut row = 0; + let mut line_start = 0; + for line in text_layout.line_layouts() { + let layout = &line.unwrapped_layout; + let glyphs = layout + .runs + .iter() + .flat_map(|run| run.glyphs.iter()) + .collect::>(); + // Each row starts at a wrap boundary glyph, and ends where the next + // row starts, or at the end of the line. + let run_offsets = layout + .runs + .iter() + .scan(0, |offset, run| { + let start = *offset; + *offset += run.glyphs.len(); + Some(start) + }) + .collect::>(); + let row_starts = line + .wrap_boundaries + .iter() + .map(|boundary| run_offsets[boundary.run_ix] + boundary.glyph_ix) + .collect::>(); + let line_boxes = boxes.len(); + let mut from = 0; + for (row_in_line, to) in row_starts.iter().copied().chain([glyphs.len()]).enumerate() { + let start_x = if row_in_line == 0 { + Pixels::ZERO + } else { + glyphs[from].position.x + }; + let end_x = glyphs + .get(to) + .map_or(layout.width, |glyph| glyph.position.x); + let shift = aligned_row_left(align, align_width, end_x - start_x) - start_x; + for ix in from..to { + let glyph = glyphs[ix]; + let right = if ix + 1 < to { + glyphs[ix + 1].position.x + } else { + end_x + }; + boxes.push(GlyphBox { + text: line_start + glyph.index..line_start + glyph.index, + row: row + row_in_line, + left: shift + glyph.position.x, + right: shift + right, + }); + } + from = to; + } + + // A glyph draws its text up to where the next glyph's text starts. + let line_boxes = &mut boxes[line_boxes..]; + line_boxes.sort_by_key(|glyph| glyph.text.start); + let line_end = line_start + line.len(); + for ix in 0..line_boxes.len() { + let start = line_boxes[ix].text.start; + line_boxes[ix].text.end = line_boxes[ix + 1..] + .iter() + .map(|glyph| glyph.text.start) + .find(|next| *next > start) + .unwrap_or(line_end); + } + + row += line.wrap_boundaries.len() + 1; + line_start = line_end + 1; + } + boxes +} + +fn aligned_row_left(align: TextAlign, align_width: Pixels, width: Pixels) -> Pixels { + match align { + TextAlign::Left => Pixels::ZERO, + TextAlign::Center => (align_width - width) / 2., + TextAlign::Right => align_width - width, + } +} + +/// The boxes behind the text of `range`, as a row and the horizontal extent +/// on it, from `glyphs` sorted by the text they draw: every glyph drawing +/// some of that text, joined where they touch on a row. +fn range_boxes(glyphs: &[GlyphBox], range: Range) -> Vec<(usize, Pixels, Pixels)> { + let first = glyphs.partition_point(|glyph| glyph.text.end <= range.start); + let mut hits = glyphs[first..] + .iter() + .take_while(|glyph| glyph.text.start < range.end) + .map(|glyph| (glyph.row, glyph.left, glyph.right)) + .collect::>(); + hits.sort_by(|a, b| { + (a.0, a.1) + .partial_cmp(&(b.0, b.1)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut boxes: Vec<(usize, Pixels, Pixels)> = Vec::with_capacity(hits.len()); + for (row, left, right) in hits { + match boxes.last_mut() { + Some(last) if last.0 == row && left <= last.2 => last.2 = last.2.max(right), + _ => boxes.push((row, left, right)), + } + } + boxes +} + fn selection_for_multi_click( text: &str, text_layout: &TextLayout, @@ -1275,6 +1437,118 @@ mod line_bounds_tests { } } +#[cfg(test)] +mod range_highlight_tests { + use super::*; + use super::{ + test_draw::in_prepaint, + test_fonts::{BODY, WideMonoTextSystem}, + }; + use gpui::{AvailableSpace, TestApp, size}; + + /// Lays `text` out at `wrap_width` and returns the highlight boxes of + /// `range` with rows aligned by `align`: the left and right edges past + /// the text's origin, and the row. + fn boxes( + text: &'static str, + wrap_width: f32, + align: TextAlign, + range: Range, + ) -> Vec<(Pixels, Pixels, usize)> { + let mut app = TestApp::with_text_system(Arc::new(WideMonoTextSystem)); + in_prepaint(&mut app, move |window, cx| { + let style = TextStyle { + font_family: BODY.into(), + font_size: px(16.).into(), + ..Default::default() + }; + let styled = StyledText::new(SharedString::from(text)).with_runs(text_runs( + text.len(), + &style, + &[], + )); + let layout = styled.layout().clone(); + let mut element = styled.into_any_element(); + element.layout_as_root( + size( + AvailableSpace::Definite(px(wrap_width)), + AvailableSpace::MinContent, + ), + window, + cx, + ); + let origin = point(px(7.), px(11.)); + element.prepaint_at(origin, window, cx); + let glyphs = glyph_boxes(&layout, align, layout.bounds().size.width); + range_boxes(&glyphs, range) + .into_iter() + .map(|(row, left, right)| (left, right, row)) + .collect() + }) + } + + #[test] + fn a_highlight_starting_a_wrapped_row_paints_only_that_row() { + // Three rows of "aaaa ", "bbbb ", "cccc". + let text = "aaaa bbbb cccc"; + let rows = boxes(text, 45., TextAlign::Left, 0..text.len()); + assert_eq!(rows.len(), 3, "{rows:?}"); + + let bbbb = text.find("bbbb").unwrap(); + let highlight = boxes(text, 45., TextAlign::Left, bbbb..bbbb + 4); + assert_eq!(highlight.len(), 1, "{highlight:?}"); + assert_eq!(highlight[0].0, px(0.)); + assert_eq!(highlight[0].2, 1); + + // Across the wrap, each row only as far as its text. + let across = boxes(text, 45., TextAlign::Left, 2..7); + assert_eq!(across.len(), 2, "{across:?}"); + assert_eq!(across[0].1, rows[0].1); + assert_eq!((across[1].0, across[1].2), (px(0.), 1)); + } + + #[test] + fn highlights_after_a_hard_line_break_start_on_its_row() { + // Rows "aa", "bbb cc": the break is one byte of the text, not a glyph. + let text = "aa\nbbb cc"; + let bbb = text.find("bbb").unwrap(); + let aa = boxes(text, 1000., TextAlign::Left, 0..2); + let highlight = boxes(text, 1000., TextAlign::Left, bbb..bbb + 3); + assert_eq!(highlight.len(), 1, "{highlight:?}"); + assert_eq!(highlight[0].0, px(0.)); + assert_eq!(highlight[0].2, 1); + // Three glyphs as wide as the two of "aa" and a half. + assert_eq!(highlight[0].1, (aa[0].1 - aa[0].0) * 1.5); + } + + #[test] + fn highlights_follow_centered_and_right_aligned_rows() { + // Rows "aaaa ", "bbbb ", "cc": the last is narrower than the text, so + // alignment moves it. + let text = "aaaa bbbb cc"; + let cc = text.find("cc").unwrap()..text.len(); + let widest = boxes(text, 45., TextAlign::Left, 0..5)[0].1; + let left = boxes(text, 45., TextAlign::Left, cc.clone())[0]; + let center = boxes(text, 45., TextAlign::Center, cc.clone())[0]; + let right = boxes(text, 45., TextAlign::Right, cc)[0]; + let width = left.1 - left.0; + assert!(width > px(0.) && width < widest, "{left:?} in {widest:?}"); + // GPUI aligns each row in the width of the laid-out text, the wrap + // width here. + let align_width = px(45.); + assert_eq!(left.0, px(0.)); + assert_eq!( + center, + ( + (align_width - width) / 2., + (align_width + width) / 2., + left.2 + ) + ); + assert_eq!(right, (align_width - width, align_width, left.2)); + } +} + /// A platform text system for tests where the `Mono` family shapes twice as /// wide as every other family, so a measurement that ignores the family of a /// run comes out visibly short. @@ -1513,8 +1787,12 @@ pub(super) mod test_fonts { #[cfg(test)] mod tests { - use super::{InlineHighlight, combine_highlights, point_in_text_selection, text_runs}; - use gpui::{FontWeight, HighlightStyle, SharedString, TextStyle, point, px}; + use super::{ + GlyphBox, InlineHighlight, aligned_row_left, combine_highlights, point_in_text_selection, + range_boxes, text_runs, + }; + use gpui::{FontWeight, HighlightStyle, SharedString, TextAlign, TextStyle, point, px}; + use std::ops::Range; fn mono(style: HighlightStyle) -> InlineHighlight { InlineHighlight { @@ -1541,6 +1819,61 @@ mod tests { assert_eq!(families, vec![(4, "Body"), (4, "Mono"), (4, "Body")]); } + #[test] + fn rows_align_the_way_gpui_paints_them() { + let width = px(100.); + assert_eq!(aligned_row_left(TextAlign::Left, width, px(40.)), px(0.)); + assert_eq!(aligned_row_left(TextAlign::Center, width, px(40.)), px(30.)); + assert_eq!(aligned_row_left(TextAlign::Right, width, px(40.)), px(60.)); + } + + fn glyph(text: Range, row: usize, left: f32, right: f32) -> GlyphBox { + GlyphBox { + text, + row, + left: px(left), + right: px(right), + } + } + + #[test] + fn range_boxes_join_the_glyphs_of_a_range_on_each_row() { + // "ab cd" wrapped after the space, glyphs 8px wide. + let glyphs = [ + glyph(0..1, 0, 0., 8.), + glyph(1..2, 0, 8., 16.), + glyph(2..3, 0, 16., 24.), + glyph(3..4, 1, 0., 8.), + glyph(4..5, 1, 8., 16.), + ]; + let boxes = |range| range_boxes(&glyphs, range); + assert_eq!(boxes(1..2), [(0, px(8.), px(16.))]); + assert_eq!(boxes(0..5), [(0, px(0.), px(24.)), (1, px(0.), px(16.))]); + // Starting at the wrap paints the next row only. + assert_eq!(boxes(3..5), [(1, px(0.), px(16.))]); + assert!(boxes(2..2).is_empty()); + } + + #[test] + fn range_boxes_follow_right_to_left_glyphs() { + // Three two-byte letters painted right to left: the first letter's + // glyph is the rightmost. + let glyphs = [ + glyph(0..2, 0, 16., 24.), + glyph(2..4, 0, 8., 16.), + glyph(4..6, 0, 0., 8.), + ]; + assert_eq!(range_boxes(&glyphs, 0..2), [(0, px(16.), px(24.))]); + assert_eq!(range_boxes(&glyphs, 2..6), [(0, px(0.), px(16.))]); + } + + #[test] + fn range_boxes_cover_a_glyph_drawing_part_of_the_range() { + // A ligature drawing "fi" in one glyph. + let glyphs = [glyph(0..2, 0, 0., 10.), glyph(2..3, 0, 10., 15.)]; + assert_eq!(range_boxes(&glyphs, 1..2), [(0, px(0.), px(10.))]); + } + #[test] fn combine_highlights_cuts_a_bold_span_at_the_code_boundary() { // `**bold `code`**`: the bold mark spans the code mark, so the diff --git a/crates/base/src/text/inline_flow.rs b/crates/base/src/text/inline_flow.rs index 6d99a1cf96..c2e62c8a83 100644 --- a/crates/base/src/text/inline_flow.rs +++ b/crates/base/src/text/inline_flow.rs @@ -50,6 +50,9 @@ pub(super) enum InlineFlowItem { text: SharedString, links: Vec<(Range, LinkMark)>, highlights: Vec<(Range, InlineHighlight)>, + /// Range highlight backgrounds, in this item's byte space. They are + /// only painted, so unlike `highlights` they take no part in layout. + backgrounds: Vec<(Range, Hsla)>, }, Image { source: ImageSource, @@ -492,6 +495,7 @@ impl Element for InlineFlow { } => { let InlineFlowItem::Text { state: source_state, + backgrounds, .. } = &self.items[*item_ix] else { @@ -530,6 +534,12 @@ impl Element for InlineFlow { self.link_click_handler.clone(), ) .selection_source(source_state.clone(), source_range.clone()) + .range_backgrounds(slice_ranges( + backgrounds, + source_range.start, + source_range.end, + |range, color| (range, *color), + )) .text_style(fragment_style.clone()) .selection_bounds(Bounds::new( point(bounds.left(), bounds.top() + selection_bounds.top()), diff --git a/crates/base/src/text/mod.rs b/crates/base/src/text/mod.rs index c5f7fbfddf..0332ddedac 100644 --- a/crates/base/src/text/mod.rs +++ b/crates/base/src/text/mod.rs @@ -8,6 +8,7 @@ mod inline_object; mod inline_virtual_tests; mod markdown_ext; mod node; +mod range_highlight; pub(crate) mod selection; mod selection_adapter; mod state; @@ -20,6 +21,7 @@ use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Window}; pub use inline_element::*; pub use markdown_ext::*; pub use node::{CodeBlock, TableData}; +pub use range_highlight::{RangeHighlight, RangeHighlightError, RenderedText}; pub use state::*; pub use stream_fade::TextViewMotion; pub use style::*; diff --git a/crates/base/src/text/node.rs b/crates/base/src/text/node.rs index abfd5c6962..392570e211 100644 --- a/crates/base/src/text/node.rs +++ b/crates/base/src/text/node.rs @@ -25,6 +25,7 @@ use crate::{ text_size_ranges, }, inline_flow::{InlineFlow, InlineFlowItem, slice_ranges}, + range_highlight::RangeHighlightFrame, stream_fade::{StreamFadeFrame, TextLeafKey}, text_view::handle_link_click, }, @@ -1862,6 +1863,7 @@ impl CodeBlock { cx: &mut App, ) -> AnyElement { let style = &node_cx.style; + let leaf_key = self.span.map(|span| TextLeafKey::block(span.start)); let block = div() .w_full() @@ -1872,22 +1874,25 @@ impl CodeBlock { .text_size(cx.theme().tokens.typography.mono_md.size) .relative() .refine_style(&style.code_block()) - .child(Inline::new( - self.state.clone(), - vec![], - fade_highlights( - node_cx - .code_block_highlighter - .as_ref() - .map(|highlighter| self.highlighted_styles(highlighter)) - .unwrap_or_default() - .into_iter() - .map(|(range, style)| (range, InlineHighlight::from(style))) - .collect(), - node_cx.stream_fades(self.span.map(|span| TextLeafKey::block(span.start))), - ), - node_cx.link_click_handler.clone(), - )); + .child( + Inline::new( + self.state.clone(), + vec![], + fade_highlights( + node_cx + .code_block_highlighter + .as_ref() + .map(|highlighter| self.highlighted_styles(highlighter)) + .unwrap_or_default() + .into_iter() + .map(|(range, style)| (range, InlineHighlight::from(style))) + .collect(), + node_cx.stream_fades(leaf_key), + ), + node_cx.link_click_handler.clone(), + ) + .range_backgrounds(node_cx.range_backgrounds(leaf_key).to_vec()), + ); // The id scopes the caller's action ids per code block, so plain ids // like `"copy"` don't collide across blocks; without actions nothing // under the block needs element state. @@ -1934,6 +1939,8 @@ pub(crate) struct NodeContext { pub(crate) markdown_extensions: Arc, /// This frame's streamed fade-in, when any text is still fading. pub(crate) stream_fade: Option>, + /// The application's range highlights, when there are any. + pub(crate) range_highlights: Option>, } impl NodeContext { @@ -1948,6 +1955,15 @@ impl NodeContext { _ => &[], } } + + /// The range highlight backgrounds of the text leaf `key`, in its + /// rendered byte space. + fn range_backgrounds(&self, key: Option) -> &[(Range, Hsla)] { + match (&self.range_highlights, key) { + (Some(frame), Some(key)) => frame.backgrounds(key), + _ => &[], + } + } } impl PartialEq for NodeContext { @@ -2023,9 +2039,9 @@ impl Paragraph { highlights } - /// `fade_key` names this paragraph's text for the streamed fade-in; the - /// owning block supplies it because a heading or table cell paragraph - /// carries no span of its own. + /// `fade_key` names this paragraph's text for the streamed fade-in and + /// for range highlights; the owning block supplies it because a heading + /// or table cell paragraph carries no span of its own. fn render( &self, fade_key: Option, @@ -2035,11 +2051,12 @@ impl Paragraph { ) -> AnyElement { let children = &self.children; let fades = node_cx.stream_fades(fade_key); + let backgrounds = node_cx.range_backgrounds(fade_key); if self.should_render_inline_flow() { return InlineFlow::new( leaf_element_id(fade_key), - self.inline_flow_items(fades, node_cx, cx), + self.inline_flow_items(fades, backgrounds, node_cx, cx), node_cx.link_click_handler.clone(), ) .into_any_element(); @@ -2061,6 +2078,7 @@ impl Paragraph { } } let highlights = fade_highlights(highlights, &slice_fades(fades, 0, text.len())); + let backgrounds = slice_backgrounds(backgrounds, 0, text.len()); if let Ok(mut state) = self.state.lock() { state.set_text(text); } @@ -2070,6 +2088,7 @@ impl Paragraph { highlights, node_cx.link_click_handler.clone(), ) + .range_backgrounds(backgrounds) .into_any_element(); } @@ -2102,6 +2121,11 @@ impl Paragraph { ), node_cx.link_click_handler.clone(), ) + .range_backgrounds(slice_backgrounds( + backgrounds, + consumed, + consumed + text.len(), + )) .into_any_element(), ); } @@ -2181,10 +2205,8 @@ impl Paragraph { // Add the last text node if text.len() > 0 { - let highlights = fade_highlights( - highlights, - &slice_fades(fades, consumed, consumed + text.len()), - ); + let text_end = consumed + text.len(); + let highlights = fade_highlights(highlights, &slice_fades(fades, consumed, text_end)); if let Ok(mut state) = self.state.lock() { state.set_text(text.into()); } @@ -2195,6 +2217,7 @@ impl Paragraph { highlights, node_cx.link_click_handler.clone(), ) + .range_backgrounds(slice_backgrounds(backgrounds, consumed, text_end)) .into_any_element(), ); } @@ -2228,6 +2251,7 @@ impl Paragraph { fn inline_flow_items( &self, fades: &[(Range, f32)], + backgrounds: &[(Range, Hsla)], node_cx: &NodeContext, cx: &mut App, ) -> Vec { @@ -2237,7 +2261,7 @@ impl Paragraph { let mut links: Vec<(Range, LinkMark)> = vec![]; let mut offset = 0; // Where `text` starts in the paragraph's whole rendered text, which - // is the byte space the fade ranges use. + // is the byte space the fade and background ranges use. let mut consumed = 0; for inline_node in &self.children { @@ -2247,12 +2271,15 @@ impl Paragraph { } if !text.is_empty() { let item_fades = slice_fades(fades, consumed, consumed + text.len()); + let item_backgrounds = + slice_backgrounds(backgrounds, consumed, consumed + text.len()); consumed += text.len(); items.push(InlineFlowItem::Text { state: inline_node.state.clone(), text: std::mem::take(&mut text).into(), links: std::mem::take(&mut links), highlights: fade_highlights(std::mem::take(&mut highlights), &item_fades), + backgrounds: item_backgrounds, }); } let mut object_style = HighlightStyle::default(); @@ -2307,6 +2334,11 @@ impl Paragraph { highlights.clone(), &slice_fades(fades, consumed, consumed + text.len()), ), + backgrounds: slice_backgrounds( + backgrounds, + consumed, + consumed + text.len(), + ), }); } @@ -2361,11 +2393,13 @@ impl Paragraph { highlights, &slice_fades(fades, consumed, consumed + text.len()), ); + let backgrounds = slice_backgrounds(backgrounds, consumed, consumed + text.len()); items.push(InlineFlowItem::Text { state: self.state.clone(), text: text.into(), links, highlights, + backgrounds, }); } @@ -2408,6 +2442,16 @@ fn slice_fades( slice_ranges(fades, start, end, |range, fade_out| (range, *fade_out)) } +/// The range highlight backgrounds overlapping `start..end`, rebased to +/// start at `start`. +fn slice_backgrounds( + backgrounds: &[(Range, Hsla)], + start: usize, + end: usize, +) -> Vec<(Range, Hsla)> { + slice_ranges(backgrounds, start, end, |range, color| (range, *color)) +} + const CELL_PAD_PX: f32 = 16.0; // px_2 horizontal padding const CELL_MIN_PX: f32 = 48.0; const CELL_BORDER_PX: f32 = 1.0; // border_r_1 drawn by every column but the last @@ -2437,7 +2481,7 @@ fn measure_table_columns( .iter() .any(|node| node.custom.is_some()) { - let items = cell.children.inline_flow_items(&[], node_cx, cx); + let items = cell.children.inline_flow_items(&[], &[], node_cx, cx); let width = super::inline_flow::intrinsic_width(&items, window, cx); let border = if ix + 1 < col_count { CELL_BORDER_PX @@ -3417,7 +3461,7 @@ mod tests { ], ..Default::default() }; - let items = paragraph.inline_flow_items(&[], &node_cx, cx); + let items = paragraph.inline_flow_items(&[], &[], &node_cx, cx); let InlineFlowItem::Object { style, link, .. } = &items[0] else { panic!() }; diff --git a/crates/base/src/text/range_highlight.rs b/crates/base/src/text/range_highlight.rs new file mode 100644 index 0000000000..80f1d7bd48 --- /dev/null +++ b/crates/base/src/text/range_highlight.rs @@ -0,0 +1,592 @@ +//! Application-supplied highlights over the text a [`TextViewState`] renders. +//! +//! Ranges address the rendered text, the string plain copy produces: an +//! application searches [`TextViewState::rendered_text`] and hands the ranges +//! it found back. Each range is split into the text leaves it covers (a +//! paragraph, a heading, a code block, a table cell), which paint it as a +//! background behind their glyphs, so a highlight never changes layout. Text +//! outside every leaf (the separators between blocks and cells, custom blocks, +//! HTML blocks, inline objects) is left unpainted. +//! +//! [`TextViewState`]: super::TextViewState +//! [`TextViewState::rendered_text`]: super::TextViewState::rendered_text + +use std::{ + ops::Range, + sync::{Arc, OnceLock}, +}; + +use gpui::{EntityId, Hsla, SharedString}; + +use super::{ + document::ParsedDocument, + node::{BlockNode, Paragraph, Table}, + stream_fade::{TextLeaf, TextLeafKey, text_leaves}, +}; + +/// The text a [`TextViewState`](super::TextViewState) renders, as of one +/// parse of its content. +/// +/// Offsets into it are UTF-8 byte offsets. It is the string plain copy +/// produces: `hello **world**` renders as `hello world`, escapes are +/// resolved, and heading markers and list markers are left out. Blocks end +/// with a newline and table cells are joined with a space; those separators +/// belong to no block, so no highlight paints them. +/// +/// Two snapshots are equal when they come from the same view and the same +/// parse. Comparing the current [`rendered_text`] with the one last searched +/// tells an observer of the view whether its content changed, so setting +/// highlights, which notifies the view too, does not start another search. +/// The text itself is only built when it is first read, from the parsed +/// document the snapshot holds on to, so drop a snapshot that is no longer +/// needed rather than keeping it past many changes. +/// +/// [`rendered_text`]: super::TextViewState::rendered_text +#[derive(Clone)] +pub struct RenderedText { + owner: EntityId, + revision: usize, + document: ParsedDocument, + index: Arc>, +} + +impl std::fmt::Debug for RenderedText { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RenderedText") + .field("owner", &self.owner) + .field("revision", &self.revision) + .finish_non_exhaustive() + } +} + +impl RenderedText { + /// The text of `document`, whose index `index` holds once built. + pub(super) fn new( + owner: EntityId, + revision: usize, + document: ParsedDocument, + index: Arc>, + ) -> Self { + Self { + owner, + revision, + document, + index, + } + } + + /// The rendered text. + pub fn as_str(&self) -> &str { + &self.index().text + } + + /// The length of the rendered text, in bytes. + pub fn len(&self) -> usize { + self.index().text.len() + } + + /// Whether the view renders no text. + pub fn is_empty(&self) -> bool { + self.index().text.is_empty() + } + + pub(super) fn owner(&self) -> EntityId { + self.owner + } + + pub(super) fn revision(&self) -> usize { + self.revision + } + + pub(super) fn index(&self) -> &RenderedIndex { + self.index + .get_or_init(|| RenderedIndex::new(&self.document)) + } +} + +impl PartialEq for RenderedText { + fn eq(&self, other: &Self) -> bool { + self.owner == other.owner && self.revision == other.revision + } +} + +impl Eq for RenderedText {} + +/// A background painted behind one range of a [`RenderedText`]. +/// +/// It is painted under the text and under the selection, and never changes +/// layout. Where highlights overlap, the later one paints over the earlier. A +/// highlight without a background paints nothing. +#[derive(Clone, Debug, PartialEq)] +pub struct RangeHighlight { + range: Range, + background: Option, +} + +impl RangeHighlight { + /// A highlight over `range`, in byte offsets of a [`RenderedText`]. + pub fn new(range: Range) -> Self { + Self { + range, + background: None, + } + } + + /// The color painted behind the range. + pub fn with_background(mut self, background: impl Into) -> Self { + self.background = Some(background.into()); + self + } + + pub fn range(&self) -> Range { + self.range.clone() + } + + pub fn background(&self) -> Option { + self.background + } +} + +/// Why [`TextViewState::set_range_highlights`](super::TextViewState::set_range_highlights) +/// rejected a set of highlights. Nothing changes when it does. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum RangeHighlightError { + /// The text was taken before the view's content last changed; take it + /// again and search the new text. + StaleText, + /// The text was taken from another view. + ForeignText, + /// The view renders HTML, which records no source positions to address + /// its text by. + Unsupported, + /// The highlight at this index is reversed, out of bounds, or not on a + /// character boundary. + InvalidRange(usize), +} + +impl std::fmt::Display for RangeHighlightError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::StaleText => f.write_str("the rendered text changed since it was taken"), + Self::ForeignText => f.write_str("the rendered text belongs to another view"), + Self::Unsupported => f.write_str("HTML views do not support range highlights"), + Self::InvalidRange(ix) => write!(f, "highlight {ix} is not a range of the text"), + } + } +} + +impl std::error::Error for RangeHighlightError {} + +/// The rendered text of one parsed document, and where each text leaf sits +/// in it. +#[derive(Debug, Default)] +pub(super) struct RenderedIndex { + text: SharedString, + /// In document order, so by their position in `text`. + leaves: Vec, +} + +#[derive(Debug)] +struct LeafSpan { + /// Where the leaf's text sits in the rendered text. + range: Range, + key: TextLeafKey, + /// Inline objects in the leaf's text, in leaf offsets. They paint as + /// objects rather than as text, so no highlight paints them. + objects: Vec>, +} + +impl RenderedIndex { + pub(super) fn new(document: &ParsedDocument) -> Self { + let mut builder = IndexBuilder::default(); + for block in document.blocks.iter() { + builder.push_block(block); + } + let index = Self { + text: builder.text.into(), + leaves: builder.leaves, + }; + debug_assert_eq!(index.text.as_ref(), document.text()); + index + } + + /// The leaf ranges `range` paints over, which are none when it covers no + /// leaf text, or `None` when it is not a range of the text. + fn resolve(&self, range: &Range) -> Option)>> { + if range.start > range.end + || range.end > self.text.len() + || !self.text.is_char_boundary(range.start) + || !self.text.is_char_boundary(range.end) + { + return None; + } + + let first = self + .leaves + .partition_point(|leaf| leaf.range.end <= range.start); + let mut pieces = Vec::new(); + for leaf in &self.leaves[first..] { + if leaf.range.start >= range.end { + break; + } + let end = range.end.min(leaf.range.end) - leaf.range.start; + let mut cursor = range.start.max(leaf.range.start) - leaf.range.start; + for object in &leaf.objects { + if object.start >= end { + break; + } + if object.end <= cursor { + continue; + } + if object.start > cursor { + pieces.push((leaf.key, cursor..object.start)); + } + cursor = object.end; + } + if cursor < end { + pieces.push((leaf.key, cursor..end)); + } + } + Some(pieces) + } +} + +/// Builds the rendered text the way `BlockNode::text` does, recording each +/// leaf as it goes. +#[derive(Default)] +struct IndexBuilder { + text: String, + leaves: Vec, +} + +impl IndexBuilder { + fn push_block(&mut self, block: &BlockNode) { + let start = self.text.len(); + match block { + BlockNode::Root { children, .. } | BlockNode::Blockquote { children, .. } => { + for child in children { + self.push_block(child); + } + } + BlockNode::List { children, .. } | BlockNode::ListItem { children, .. } => { + for child in children { + self.push_block(child); + } + return; + } + BlockNode::Paragraph(paragraph) => { + self.push_paragraph( + paragraph, + paragraph.span.map(|span| TextLeafKey::block(span.start)), + ); + } + BlockNode::Heading { children, span, .. } => { + self.push_paragraph(children, span.map(|span| TextLeafKey::block(span.start))); + } + BlockNode::Table(table) => { + let mut ordinal = 0; + for row in table.children.iter().filter(|row| !row.children.is_empty()) { + for (ix, cell) in row.children.iter().enumerate() { + if ix > 0 { + self.text.push(' '); + } + self.push_paragraph( + &cell.children, + table + .span + .map(|span| TextLeafKey::table_cell(span.start, ordinal)), + ); + ordinal += 1; + } + self.text.push('\n'); + } + } + BlockNode::CodeBlock(code_block) => { + self.push_leaf( + &code_block.code(), + code_block.span.map(|span| TextLeafKey::block(span.start)), + Vec::new(), + ); + } + BlockNode::Custom(node) => self.text.push_str(node.as_text()), + BlockNode::Definition { .. } + | BlockNode::Break { .. } + | BlockNode::HorizontalRule { .. } + | BlockNode::Unknown => {} + } + if self.text.len() > start { + self.text.push('\n'); + } + } + + fn push_paragraph(&mut self, paragraph: &Paragraph, key: Option) { + let mut text = String::new(); + let mut objects = Vec::new(); + for child in ¶graph.children { + if child.custom.is_some() { + objects.push(text.len()..text.len() + child.text.len()); + } + text.push_str(&child.text); + } + self.push_leaf(&text, key, objects); + } + + fn push_leaf(&mut self, text: &str, key: Option, objects: Vec>) { + let start = self.text.len(); + self.text.push_str(text); + if let Some(key) = key + && !text.is_empty() + { + self.leaves.push(LeafSpan { + range: start..self.text.len(), + key, + objects, + }); + } + } +} + +/// Where the source of the table row holding cell `cell_ix` of the table +/// starting at `table_start` ends, when the parser recorded it. +fn row_source_end(blocks: &[BlockNode], table_start: usize, cell_ix: usize) -> Option { + fn find_table(blocks: &[BlockNode], start: usize) -> Option<&Table> { + blocks.iter().find_map(|block| match block { + BlockNode::Table(table) if table.span.map(|span| span.start) == Some(start) => { + Some(table) + } + BlockNode::Root { children, .. } + | BlockNode::Blockquote { children, .. } + | BlockNode::List { children, .. } + | BlockNode::ListItem { children, .. } => find_table(children, start), + _ => None, + }) + } + + let mut first_cell = 0; + for row in &find_table(blocks, table_start)?.children { + if cell_ix < first_cell + row.children.len() { + return row + .children + .iter() + .filter_map(|cell| paragraph_source_end(&cell.children)) + .max(); + } + first_cell += row.children.len(); + } + None +} + +/// Where the source of `paragraph`'s text ends, when the parser recorded it. +fn paragraph_source_end(paragraph: &Paragraph) -> Option { + paragraph + .children + .iter() + .flat_map(|node| { + node.source_segments + .iter() + .map(|segment| segment.source.end) + .chain( + node.custom + .as_ref() + .and_then(|custom| custom.source_range()) + .map(|range| range.end), + ) + }) + .max() +} + +/// The highlights each leaf paints, resolved once when they change so +/// rendering only looks up its leaf. +#[derive(Debug, Default)] +pub(crate) struct RangeHighlightFrame { + /// Sorted by key. A leaf's backgrounds keep the order the application + /// gave them in, so a later one paints over an earlier one. + leaves: Vec<(TextLeafKey, Vec<(Range, Hsla)>)>, +} + +impl RangeHighlightFrame { + /// Validates `highlights` against `text` and resolves them to leaves. + pub(super) fn new( + text: &RenderedText, + highlights: impl IntoIterator, + ) -> Result, RangeHighlightError> { + let mut pieces = Vec::new(); + for (ix, highlight) in highlights.into_iter().enumerate() { + let leaf_ranges = text + .index() + .resolve(&highlight.range) + .ok_or(RangeHighlightError::InvalidRange(ix))?; + if let Some(background) = highlight.background { + pieces.extend( + leaf_ranges + .into_iter() + .map(|(key, range)| (key, range, background)), + ); + } + } + + // Stable, so each leaf keeps the application's order. + pieces.sort_by_key(|(key, _, _)| *key); + let mut leaves: Vec<(TextLeafKey, Vec<(Range, Hsla)>)> = Vec::new(); + for (key, range, background) in pieces { + match leaves.last_mut() { + Some((last, backgrounds)) if *last == key => backgrounds.push((range, background)), + _ => leaves.push((key, vec![(range, background)])), + } + } + Ok((!leaves.is_empty()).then_some(Self { leaves })) + } + + /// The backgrounds of leaf `key`, in its rendered byte space. + pub(crate) fn backgrounds(&self, key: TextLeafKey) -> &[(Range, Hsla)] { + self.leaves + .binary_search_by_key(&key, |(leaf, _)| *leaf) + .map_or(&[], |ix| self.leaves[ix].1.as_slice()) + } + + /// The highlights that still describe `new`, the document parsed after + /// `old`. + /// + /// A block that starts before the first change of the source is found at + /// the same offset in `new`, and one after the last change at an offset + /// moved by the change in length; a block that starts between them is + /// gone. A highlight follows its block, as far as the text of its leaf is + /// unchanged, and is dropped with a leaf that is gone. + /// + /// With `tail_only`, `new` was parsed by appending to `old`, which parses + /// only the last block of `old` again and keeps the others as they were. + pub(super) fn remap( + &self, + old: &ParsedDocument, + new: &ParsedDocument, + tail_only: bool, + ) -> Option { + let (old_len, new_len) = (old.source.len(), new.source.len()); + // An append starts after the old source, and parses its last block + // again, or only the new text when that block has no span. + let tail_start = tail_only.then(|| { + old.blocks + .last() + .and_then(BlockNode::span) + .map_or(old_len, |span| span.start) + }); + let (unchanged_prefix, unchanged_suffix) = if tail_only { + (old_len, 0) + } else { + let prefix = old + .source + .bytes() + .zip(new.source.bytes()) + .take_while(|(old, new)| old == new) + .count(); + let shorter = old_len.min(new_len); + if prefix == shorter { + // One source extends the other, as when text is appended. + (prefix, 0) + } else { + // Where the two overlap, as when a deleted block starts like + // the block after it, the end wins: the blocks after a change + // keep following their text rather than their offset. + let suffix = old + .source + .bytes() + .rev() + .zip(new.source.bytes().rev()) + .take_while(|(old, new)| old == new) + .count() + .min(shorter); + (prefix.min(shorter - suffix), suffix) + } + }; + // Where the block starting at `start` in `old` starts in `new`. + let moved = |start: usize| { + if start < unchanged_prefix { + Some(start) + } else if start >= old_len - unchanged_suffix { + Some(start + new_len - old_len) + } else { + None + } + }; + + fn leaves_from( + document: &ParsedDocument, + tail_start: Option, + ) -> Vec<(TextLeafKey, TextLeaf<'_>)> { + let mut leaves = Vec::new(); + for block in document.blocks.iter().rev() { + if let Some(tail_start) = tail_start + && block.span().is_none_or(|span| span.start < tail_start) + { + break; + } + text_leaves(block, &mut leaves); + } + leaves.sort_by_key(|(key, _)| *key); + leaves + } + fn find<'a>( + leaves: &'a [(TextLeafKey, TextLeaf<'a>)], + key: TextLeafKey, + ) -> Option<&'a TextLeaf<'a>> { + let ix = leaves.binary_search_by_key(&key, |(leaf, _)| *leaf).ok()?; + Some(&leaves[ix].1) + } + let old_leaves = leaves_from(old, tail_start); + let new_leaves = leaves_from(new, tail_start); + + let mut leaves = self + .leaves + .iter() + .filter_map(|(key, backgrounds)| { + if tail_start.is_some_and(|tail_start| key.block_start() < tail_start) { + return Some((*key, backgrounds.clone())); + } + let new_key = key.moved_to(moved(key.block_start())?); + let old_leaf = find(&old_leaves, *key)?; + // A table's cells are only known by their place in it, so + // after a change inside the table a cell is the same one only + // when the source of its whole row ends before that change. + if let Some(cell_ix) = key.cell_ix() + && key.block_start() < unchanged_prefix + && tail_start.is_none() + && row_source_end(&old.blocks, key.block_start(), cell_ix) + .is_none_or(|end| end > unchanged_prefix) + { + return None; + } + let new_leaf = find(&new_leaves, new_key)?; + let prefix = new_leaf.common_prefix_len(old_leaf); + let clipped = backgrounds + .iter() + .filter(|(range, _)| range.start < prefix) + .map(|(range, background)| (range.start..range.end.min(prefix), *background)) + .collect::>(); + (!clipped.is_empty()).then_some((new_key, clipped)) + }) + .collect::>(); + // Moving keys keeps their order, but stay safe for the binary search. + leaves.sort_by_key(|(key, _)| *key); + (!leaves.is_empty()).then_some(Self { leaves }) + } +} + +#[cfg(test)] +mod tests { + use gpui::hsla; + + use super::RangeHighlight; + + #[test] + fn test_range_highlight_builder() { + let highlight = RangeHighlight::new(2..5); + assert_eq!(highlight.range(), 2..5); + assert_eq!(highlight.background(), None); + + let color = hsla(0.15, 1., 0.5, 0.4); + let highlight = highlight.with_background(color); + assert_eq!(highlight.range(), 2..5); + assert_eq!(highlight.background(), Some(color)); + } +} diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index 768273ffdb..1116524ec7 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -4,15 +4,15 @@ use std::time::Instant; use std::{ ops::{Range, RangeInclusive}, pin::Pin, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, OnceLock}, task::Poll, }; #[cfg(target_family = "wasm")] use web_time::Instant; use gpui::{ - App, AppContext as _, Bounds, Context, FocusHandle, IntoElement, KeyBinding, ListState, - ParentElement as _, Pixels, Point, Render, SharedString, Styled as _, Task, Window, + App, AppContext as _, Bounds, Context, EntityId, FocusHandle, IntoElement, KeyBinding, + ListState, ParentElement as _, Pixels, Point, Render, SharedString, Styled as _, Task, Window, prelude::FluentBuilder as _, px, }; @@ -22,10 +22,11 @@ use crate::{ input::{self, SelectAll}, text::{ CodeBlockActionsFn, CodeBlockHighlighterFn, LinkClickHandlerFn, MarkdownExtensions, - TableActionsFn, TextViewStyle, + RangeHighlight, RangeHighlightError, RenderedText, TableActionsFn, TextViewStyle, document::ParsedDocument, format, node::{self, NodeContext}, + range_highlight::{RangeHighlightFrame, RenderedIndex}, selection_adapter::TextViewSelectionAdapter, stream_fade::{StreamFadeTracker, TextViewMotion}, }, @@ -89,6 +90,7 @@ pub(super) struct LineSpan { /// The state of a TextView. pub struct TextViewState { + entity_id: EntityId, pub(super) focus_handle: FocusHandle, pub(super) list_state: ListState, @@ -130,6 +132,14 @@ pub struct TextViewState { /// string next frame without comparing its bytes. element_text: Option, revision: usize, + /// The revision of the update `parsed_content` was committed from. + committed_revision: usize, + /// The revision of the last update that replaced the text rather than + /// appending to it. + full_update_revision: usize, + /// The rendered text of `parsed_content`, built when first read. + rendered_index: Arc>, + range_highlights: Option>, pub(super) selection_revision: usize, compatible_layout_update: bool, layout_text_style: Option<(gpui::TextStyle, Pixels)>, @@ -171,6 +181,11 @@ impl TextViewState { match parsed_update.result { Ok(content) => { + state.reconcile_range_highlights( + &content.document, + parsed_update.revision, + parsed_update.selection_compatible && !parsed_update.full_parse, + ); state.stream_fade.record( &state.parsed_content.document, &content.document, @@ -203,6 +218,7 @@ impl TextViewState { let _parse_task = cx.background_spawn(UpdateFuture::new(format, rx, tx_result)); let mut this = Self { + entity_id: cx.entity_id(), focus_handle, bounds: Bounds::default(), multi_click_selection: None, @@ -236,6 +252,10 @@ impl TextViewState { text: text.to_string(), element_text: None, revision: 0, + committed_revision: 0, + full_update_revision: 0, + rendered_index: Arc::default(), + range_highlights: None, selection_revision: 0, compatible_layout_update: false, layout_text_style: None, @@ -487,6 +507,7 @@ impl TextViewState { fn increment_update(&mut self, text: &str, append: bool, cx: &mut Context) { self.revision += 1; if !append { + self.full_update_revision = self.revision; self.selection_revision = self.selection_revision.wrapping_add(1); } let parse_synchronously = !append && text.len() <= MAX_SYNC_FULL_REPLACE_BYTES; @@ -510,6 +531,7 @@ impl TextViewState { if parse_synchronously { match parse_content(self.format, ParsedContent::default(), &update_options) { Ok(content) => { + self.reconcile_range_highlights(&content.document, self.revision, false); self.stream_fade.record( &self.parsed_content.document, &content.document, @@ -538,6 +560,81 @@ impl TextViewState { _ = self.tx.try_send(update_options); } + /// The text this view renders, which [`RangeHighlight`] ranges index. + /// + /// This is the string plain copy produces, as of the last parse that + /// landed; text set since then is not in it until its parse lands. + pub fn rendered_text(&self) -> RenderedText { + RenderedText::new( + self.entity_id, + self.committed_revision, + self.parsed_content.document.clone(), + self.rendered_index.clone(), + ) + } + + /// Replace the range highlights, whose ranges index `text`. + /// + /// A highlight crossing from one block into another is painted in both, + /// skipping the separator between them; text outside every block's text + /// (those separators, custom blocks, HTML blocks, inline objects) is left + /// unpainted. The whole set is rejected when `text` is out of date or any + /// range is not a range of it. + /// + /// When the content changes, a highlight follows its block and stays as + /// far as the block's text is unchanged, and the view notifies: text + /// appended while streaming keeps the highlights before it, and an edit + /// keeps those before and after it. After an edit inside a table, the + /// cells in and after the edited row lose theirs, as a cell is only known + /// by its place. Backgrounds that are part of the text, such as ``, + /// paint over a highlight; inline code's paints under it. + /// Search the new [`Self::rendered_text`] again to highlight the new + /// text. + pub fn set_range_highlights( + &mut self, + text: &RenderedText, + highlights: impl IntoIterator, + cx: &mut Context, + ) -> Result<(), RangeHighlightError> { + if self.format != TextViewFormat::Markdown { + return Err(RangeHighlightError::Unsupported); + } + if text.owner() != self.entity_id { + return Err(RangeHighlightError::ForeignText); + } + if text.revision() != self.committed_revision { + return Err(RangeHighlightError::StaleText); + } + self.range_highlights = RangeHighlightFrame::new(text, highlights)?.map(Arc::new); + cx.notify(); + Ok(()) + } + + /// Remove all range highlights. + pub fn clear_range_highlights(&mut self, cx: &mut Context) { + if self.range_highlights.take().is_some() { + cx.notify(); + } + } + + /// Carry the range highlights over to `new`, the document the update of + /// `revision` parsed, before it replaces the current one. + /// + /// `append` is whether that update parsed only appended text onto the + /// document before it. Only when no update has replaced the text since + /// the current document was committed was that document the current one, + /// so only then do the blocks before its last stay unchanged. + fn reconcile_range_highlights(&mut self, new: &ParsedDocument, revision: usize, append: bool) { + let tail_only = append && self.full_update_revision <= self.committed_revision; + self.committed_revision = revision; + self.rendered_index = Arc::default(); + if let Some(highlights) = self.range_highlights.take() { + self.range_highlights = highlights + .remap(&self.parsed_content.document, new, tail_only) + .map(Arc::new); + } + } + /// Save bounds and unselect if bounds changed. pub(super) fn update_bounds(&mut self, bounds: Bounds, _cx: &mut App) { self.bounds = bounds; @@ -791,6 +888,7 @@ impl Render for TextViewState { link_click_handler: self.link_click_handler.clone(), markdown_extensions: self.markdown_extensions.clone(), stream_fade, + range_highlights: self.range_highlights.clone(), }; v_flex() @@ -1760,4 +1858,490 @@ mod tests { assert_eq!(node.data::().map(String::as_str), Some("TSLA.US")); }); } + + mod range_highlights { + use std::ops::Range; + + use gpui::{Entity, TestAppContext}; + + use super::super::*; + use crate::text::stream_fade::TextLeafKey; + + fn state(markdown: &str, cx: &mut TestAppContext) -> Entity { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown(markdown, cx))); + cx.run_until_parked(); + state + } + + fn highlight(range: Range) -> RangeHighlight { + RangeHighlight::new(range).with_background(gpui::hsla(0.15, 1., 0.5, 0.4)) + } + + /// Highlights `ranges` of the current rendered text. + fn set( + state: &Entity, + ranges: impl IntoIterator>, + cx: &mut TestAppContext, + ) -> Result<(), RangeHighlightError> { + state.update(cx, |state, cx| { + let text = state.rendered_text(); + state.set_range_highlights(&text, ranges.into_iter().map(highlight), cx) + }) + } + + /// The ranges leaf `key` paints, in its own byte space. + fn painted( + state: &Entity, + key: TextLeafKey, + cx: &mut TestAppContext, + ) -> Vec> { + state.read_with(cx, |state, _| { + state.range_highlights.as_ref().map_or(Vec::new(), |frame| { + frame + .backgrounds(key) + .iter() + .map(|(range, _)| range.clone()) + .collect() + }) + }) + } + + fn has_highlights(state: &Entity, cx: &mut TestAppContext) -> bool { + state.read_with(cx, |state, _| state.range_highlights.is_some()) + } + + #[gpui::test] + fn rendered_text_is_the_plain_copy_text(cx: &mut TestAppContext) { + let state = state( + "# Title\n\nhello **world** \\*\n\n- item\n\n| a | b |\n|---|---|\n| c | d |\n\n```\nlet x\n```", + cx, + ); + state.read_with(cx, |state, _| { + assert_eq!( + state.rendered_text().as_str(), + "Title\nhello world *\nitem\na b\nc d\n\nlet x\n" + ); + }); + } + + #[gpui::test] + fn a_match_across_marks_paints_in_its_paragraph(cx: &mut TestAppContext) { + let state = state("hello **world**", cx); + set(&state, [0.."hello world".len()], cx).unwrap(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..11]); + } + + #[gpui::test] + fn repeated_text_maps_to_the_occurrence_addressed(cx: &mut TestAppContext) { + let state = state("foo\n\nfoo", cx); + let second = state.read_with(cx, |state, _| { + state.rendered_text().as_str().rfind("foo").unwrap() + }); + set(&state, [second..second + 3], cx).unwrap(); + assert!(painted(&state, TextLeafKey::block(0), cx).is_empty()); + assert_eq!(painted(&state, TextLeafKey::block(5), cx), [0..3]); + } + + #[gpui::test] + fn a_range_across_blocks_skips_the_separator(cx: &mut TestAppContext) { + let state = state("ab\n\ncd", cx); + // "ab\ncd\n": 1..4 is "b\nc". + set(&state, [1..4], cx).unwrap(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [1..2]); + assert_eq!(painted(&state, TextLeafKey::block(4), cx), [0..1]); + } + + #[gpui::test] + fn table_cells_and_code_blocks_are_leaves(cx: &mut TestAppContext) { + let state = state("| a | b |\n|---|---|\n| c | d |\n\n```\nlet x\n```", cx); + // "a b\nc d\n\nlet x\n" + set(&state, [0..3, 6..7, 9..12], cx).unwrap(); + assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]); + assert_eq!(painted(&state, TextLeafKey::table_cell(0, 1), cx), [0..1]); + assert_eq!(painted(&state, TextLeafKey::table_cell(0, 3), cx), [0..1]); + let code_start = "| a | b |\n|---|---|\n| c | d |\n\n".len(); + assert_eq!(painted(&state, TextLeafKey::block(code_start), cx), [0..3]); + } + + #[gpui::test] + fn invalid_ranges_reject_the_whole_set(cx: &mut TestAppContext) { + let state = state("中文\n\nab", cx); + // "中文\nab\n" + set(&state, [0.."中".len()], cx).unwrap(); + let invalid = [Range { start: 6, end: 3 }, 0..1, 0..100]; + for range in invalid { + assert_eq!( + set(&state, [0..3, range.clone()], cx), + Err(RangeHighlightError::InvalidRange(1)), + "{range:?}" + ); + } + // The rejected sets left the earlier highlight in place. + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]); + } + + #[gpui::test] + fn text_outside_every_block_is_left_unpainted(cx: &mut TestAppContext) { + let source = "foo one\n\n
foo two
\n\n| foo | x |\n|---|---|\n\nfoo three"; + let state = state(source, cx); + let text = state.read_with(cx, |state, _| state.rendered_text().as_str().to_string()); + // Every "foo" and " ", an empty range, and the separator after the + // first block: the HTML block's text and the separators are not + // any block's text, so they paint nothing, but nothing fails. + let ranges = text + .match_indices("foo") + .chain(text.match_indices(' ')) + .map(|(start, found)| start..start + found.len()) + .chain([3..3, 7..8]) + .collect::>(); + set(&state, ranges, cx).unwrap(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3, 3..4]); + let table = source.find("| foo").unwrap(); + assert_eq!( + painted(&state, TextLeafKey::table_cell(table, 0), cx), + [0..3] + ); + let last = source.find("foo three").unwrap(); + assert_eq!(painted(&state, TextLeafKey::block(last), cx), [0..3, 3..4]); + } + + #[gpui::test] + fn a_later_highlight_paints_over_an_earlier_one(cx: &mut TestAppContext) { + let state = state("abcdef", cx); + set(&state, [0..6, 2..3], cx).unwrap(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..6, 2..3]); + } + + #[gpui::test] + fn stale_foreign_and_html_text_is_rejected(cx: &mut TestAppContext) { + let state = state("one", cx); + let other = self::state("one", cx); + let text = state.read_with(cx, |state, _| state.rendered_text()); + other.update(cx, |other, cx| { + assert_eq!( + other.set_range_highlights(&text, [highlight(0..3)], cx), + Err(RangeHighlightError::ForeignText) + ); + }); + assert_eq!(state.read_with(cx, |state, _| state.rendered_text()), text); + assert_ne!(other.read_with(cx, |other, _| other.rendered_text()), text); + state.update(cx, |state, cx| state.set_text("two", cx)); + cx.run_until_parked(); + assert_ne!(state.read_with(cx, |state, _| state.rendered_text()), text); + state.update(cx, |state, cx| { + assert_eq!( + state.set_range_highlights(&text, [highlight(0..3)], cx), + Err(RangeHighlightError::StaleText) + ); + }); + + let html = cx.update(|cx| cx.new(|cx| TextViewState::html("

one

", cx))); + cx.run_until_parked(); + html.update(cx, |html, cx| { + let text = html.rendered_text(); + assert_eq!( + html.set_range_highlights(&text, [highlight(0..3)], cx), + Err(RangeHighlightError::Unsupported) + ); + }); + } + + #[gpui::test] + fn inline_objects_are_skipped(cx: &mut TestAppContext) { + let state = state("x $a$ y", cx); + state.update(cx, |state, cx| { + let extensions = MarkdownExtensions::default().plugin( + crate::text::markdown_ext::TestInlinePlugin::new("test").parse_with( + |node, _| { + let markdown::mdast::Node::InlineMath(math) = node else { + return None; + }; + Some( + crate::text::MarkdownNode::new("formula", ()) + .text(math.value.clone()), + ) + }, + ), + ); + state.set_markdown_extensions(Arc::new(extensions), cx); + }); + cx.run_until_parked(); + // "x a y\n", where "a" is the formula. + set(&state, [2..3], cx).unwrap(); + assert!(painted(&state, TextLeafKey::block(0), cx).is_empty()); + set(&state, [0..5], cx).unwrap(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..2, 3..5]); + } + + #[gpui::test] + fn push_str_keeps_earlier_blocks_and_clips_the_changed_tail(cx: &mut TestAppContext) { + let state = state("first\n\na **b", cx); + let text = state.read_with(cx, |state, _| state.rendered_text()); + // "first\na **b\n" + set(&state, [0..5, 6..11], cx).unwrap(); + + // Closing the emphasis renders the tail as "a bc". + state.update(cx, |state, cx| state.push_str("c**", cx)); + cx.run_until_parked(); + + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]); + assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..2]); + state.update(cx, |state, cx| { + assert_eq!( + state.set_range_highlights(&text, [highlight(0..5)], cx), + Err(RangeHighlightError::StaleText) + ); + }); + } + + #[gpui::test] + fn push_str_keeps_a_block_whose_text_is_unchanged(cx: &mut TestAppContext) { + let state = state("first\n\nsecond", cx); + set(&state, [6..12], cx).unwrap(); + // A setext underline turns the paragraph into a heading at the + // same source start, rendering the same text. + state.update(cx, |state, cx| state.push_str("\n===", cx)); + cx.run_until_parked(); + assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..6]); + } + + #[gpui::test] + fn push_str_drops_highlights_of_a_leaf_that_is_gone(cx: &mut TestAppContext) { + let state = state("first\n\n| a |", cx); + set(&state, [0..5, 6..11], cx).unwrap(); + // A delimiter row turns the paragraph into a table, whose text + // lives in cells instead. + state.update(cx, |state, cx| state.push_str("\n|---|", cx)); + cx.run_until_parked(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]); + assert!(painted(&state, TextLeafKey::block(7), cx).is_empty()); + assert!(painted(&state, TextLeafKey::table_cell(7, 0), cx).is_empty()); + } + + #[gpui::test] + fn replacing_text_drops_highlights_only_where_it_changed(cx: &mut TestAppContext) { + let state = state("first\n\nsecond", cx); + set(&state, [0..5, 6..12], cx).unwrap(); + state.update(cx, |state, cx| state.set_text("first\n\nchanged", cx)); + cx.run_until_parked(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]); + assert!(painted(&state, TextLeafKey::block(7), cx).is_empty()); + + let large = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1); + state.update(cx, |state, cx| state.set_text(&large, cx)); + cx.run_until_parked(); + assert!(!has_highlights(&state, cx)); + } + + #[gpui::test] + fn a_highlight_follows_its_block_past_an_earlier_edit(cx: &mut TestAppContext) { + let state = state("foo\n\nbar\n\nbar", cx); + // "foo\nbar\nbar\n": the first "bar", in the block at source 5. + set(&state, [4..7], cx).unwrap(); + // Deleting "foo" moves that "bar" to 0, and the second one to 5. + state.update(cx, |state, cx| state.set_text("bar\n\nbar", cx)); + cx.run_until_parked(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]); + assert!(painted(&state, TextLeafKey::block(5), cx).is_empty()); + + let state = self::state("ERROR a\n\nERROR b\n\nERROR c", cx); + set(&state, [8..15], cx).unwrap(); + state.update(cx, |state, cx| state.set_text("ERROR b\n\nERROR c", cx)); + cx.run_until_parked(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..7]); + assert!(painted(&state, TextLeafKey::block(9), cx).is_empty()); + } + + #[gpui::test] + fn appending_a_copy_of_the_last_block_keeps_the_highlight_on_it(cx: &mut TestAppContext) { + let state = state("a\n\nfoo", cx); + // "a\nfoo\n" + set(&state, [2..5], cx).unwrap(); + state.update(cx, |state, cx| state.set_text("a\n\nfoo\n\nfoo", cx)); + cx.run_until_parked(); + assert_eq!(painted(&state, TextLeafKey::block(3), cx), [0..3]); + assert!(painted(&state, TextLeafKey::block(8), cx).is_empty()); + } + + #[gpui::test] + fn deleting_a_table_row_drops_highlights_in_the_rows_it_moves(cx: &mut TestAppContext) { + let table = "| a | b |\n|---|---|\n| x | 1 |\n| x | 2 |"; + let without_middle_row = "| a | b |\n|---|---|\n| x | 2 |"; + // "a b\nx 1\nx 2\n\n": the last row's cells, then the middle + // row's "x". + for ranges in [vec![8..9, 10..11], vec![4..5]] { + let state = state(table, cx); + set(&state, [0..1].into_iter().chain(ranges), cx).unwrap(); + state.update(cx, |state, cx| state.set_text(without_middle_row, cx)); + cx.run_until_parked(); + assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]); + for cell in 1..4 { + assert!( + painted(&state, TextLeafKey::table_cell(0, cell), cx).is_empty(), + "cell {cell}" + ); + } + } + } + + #[gpui::test] + fn streaming_table_rows_keeps_the_highlights_of_earlier_rows(cx: &mut TestAppContext) { + let state = state("| a | b |\n|---|---|\n| x | 1 |", cx); + // "a b\nx 1\n\n" + set(&state, [0..1, 4..5], cx).unwrap(); + state.update(cx, |state, cx| { + state.set_text("| a | b |\n|---|---|\n| x | 1 |\n| y | 2 |", cx) + }); + cx.run_until_parked(); + assert_eq!(painted(&state, TextLeafKey::table_cell(0, 0), cx), [0..1]); + assert_eq!(painted(&state, TextLeafKey::table_cell(0, 2), cx), [0..1]); + } + + #[gpui::test] + fn an_edit_in_an_earlier_block_keeps_later_highlights(cx: &mut TestAppContext) { + let state = state("one\n\ntwo", cx); + set(&state, [0..3, 4..7], cx).unwrap(); + state.update(cx, |state, cx| state.set_text("one!\n\ntwo", cx)); + cx.run_until_parked(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..3]); + assert_eq!(painted(&state, TextLeafKey::block(6), cx), [0..3]); + } + + #[gpui::test] + fn an_append_after_a_full_update_compares_every_block(cx: &mut TestAppContext) { + let state = state("first\n\nsecond", cx); + set(&state, [0..5], cx).unwrap(); + // An append result whose baseline was a full update that has not + // been committed: its earlier blocks are not the current ones. + let options = UpdateOptions { + revision: 0, + pending_text: "FIRST\n\nsecond".to_string(), + append: false, + mode: ParseMode::Replace, + markdown_extensions: Arc::default(), + }; + let new = parse_content(TextViewFormat::Markdown, ParsedContent::default(), &options) + .unwrap() + .document; + state.update(cx, |state, _| { + state.full_update_revision = state.committed_revision + 1; + let revision = state.committed_revision + 2; + state.reconcile_range_highlights(&new, revision, true); + }); + assert!(!has_highlights(&state, cx)); + } + + #[gpui::test] + fn streaming_through_set_text_keeps_highlights(cx: &mut TestAppContext) { + let state = state("first\n\nsec", cx); + // "first\nsec\n" + set(&state, [0..5, 6..9], cx).unwrap(); + state.update(cx, |state, cx| state.set_text("first\n\nsecond", cx)); + cx.run_until_parked(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]); + assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..3]); + } + + #[gpui::test] + fn a_full_parse_merged_with_an_append_compares_every_block(cx: &mut TestAppContext) { + let state = state("", cx); + // The small text is committed at once, and its background parse + // merges with the append into one full parse, in which the + // definition turns the earlier `[foo]` into the link text `foo`. + state.update(cx, |state, cx| { + state.set_text("[foo] and some text\n\nmore", cx); + let text = state.rendered_text(); + let some = text.as_str().find("some").unwrap(); + state + .set_range_highlights(&text, [highlight(some..some + 4)], cx) + .unwrap(); + state.push_str("\n\n[foo]: https://example.com", cx); + }); + cx.run_until_parked(); + state.read_with(cx, |state, _| { + assert!(state.rendered_text().as_str().starts_with("foo and some")); + }); + assert!(painted(&state, TextLeafKey::block(0), cx).is_empty()); + } + + #[gpui::test] + fn a_full_update_before_an_append_drops_replaced_highlights(cx: &mut TestAppContext) { + let state = state("first", cx); + set(&state, [0..5], cx).unwrap(); + let large = "x".repeat(MAX_SYNC_FULL_REPLACE_BYTES + 1); + state.update(cx, |state, cx| { + state.set_text(&large, cx); + state.push_str(" tail", cx); + }); + cx.run_until_parked(); + assert!(!has_highlights(&state, cx)); + } + + #[gpui::test] + fn reparsing_unchanged_text_keeps_highlights(cx: &mut TestAppContext) { + let state = state("first", cx); + set(&state, [0..5], cx).unwrap(); + state.update(cx, |state, cx| { + state.set_markdown_extensions( + Arc::new(MarkdownExtensions::default().parser_revision(1)), + cx, + ) + }); + cx.run_until_parked(); + assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]); + } + + struct Root { + state: Entity, + } + + impl Render for Root { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::div() + .w(px(120.)) + .child(crate::text::TextView::new(&self.state).selectable(true)) + } + } + + #[gpui::test] + fn highlights_paint_across_inline_code_tables_and_code_blocks(cx: &mut TestAppContext) { + cx.update(crate::init); + let markdown = "wrapping text with `inline code` and a [link](https://x.y) \ + that wraps\n\n| a | b |\n|---|---|\n| c | d |\n\n```\nlet x = 1;\n```"; + let (root, cx) = cx.add_window_view(|_, cx| Root { + state: cx.new(|cx| TextViewState::markdown(markdown, cx)), + }); + cx.run_until_parked(); + let state = root.read_with(cx, |root, _| root.state.clone()); + let text = state.read_with(cx, |state, _| state.rendered_text().as_str().to_string()); + let code = text.find("code").unwrap(); + let len = text.len(); + // Part of the inline code, every character, and the whole text. + let ranges = [code..code + 2] + .into_iter() + .chain(text.char_indices().map(|(ix, c)| ix..ix + c.len_utf8())) + .chain([0..len]) + .filter(|range| !text[range.clone()].trim().is_empty()); + state.update(cx, |state, cx| { + let rendered = state.rendered_text(); + state + .set_range_highlights(&rendered, ranges.map(highlight), cx) + .unwrap(); + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!( + painted(&state, TextLeafKey::block(0), cx)[0], + code..code + 2 + ); + } + + #[gpui::test] + fn clear_range_highlights_removes_them(cx: &mut TestAppContext) { + let state = state("first", cx); + set(&state, [0..5], cx).unwrap(); + state.update(cx, |state, cx| state.clear_range_highlights(cx)); + assert!(!has_highlights(&state, cx)); + } + } } diff --git a/crates/base/src/text/stream_fade.rs b/crates/base/src/text/stream_fade.rs index 2c0506d1a0..20de935dd8 100644 --- a/crates/base/src/text/stream_fade.rs +++ b/crates/base/src/text/stream_fade.rs @@ -97,7 +97,9 @@ impl TextViewMotion { /// Identifies one run of rendered text across re-parses: the source start of /// the block that owns it, plus the cell ordinal inside a table. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// +/// Keys order as their leaves appear in the document. +#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] pub(crate) struct TextLeafKey { block_start: usize, ordinal: usize, @@ -129,6 +131,24 @@ impl TextLeafKey { ordinal: ordinal + 1, } } + + pub(crate) fn block_start(&self) -> usize { + self.block_start + } + + /// The index of the table cell the leaf is, among all the cells of its + /// table, or `None` when it is not a cell. + pub(crate) fn cell_ix(&self) -> Option { + self.ordinal.checked_sub(1) + } + + /// The same leaf in its block moved to start at `block_start`. + pub(crate) fn moved_to(self, block_start: usize) -> Self { + Self { + block_start, + ordinal: self.ordinal, + } + } } /// Rendered byte ranges with the [`gpui::HighlightStyle::fade_out`] factor @@ -335,7 +355,7 @@ impl StreamFadeTracker { } /// A block's rendered text, in the byte space its highlights use. -enum TextLeaf<'a> { +pub(super) enum TextLeaf<'a> { Paragraph(&'a Paragraph), Code(SharedString), } @@ -370,13 +390,13 @@ impl TextLeaf<'_> { /// The length of the rendered text `self` shares with `old`, on a char /// boundary of `self`. - fn common_prefix_len(&self, old: &Self) -> usize { + pub(super) fn common_prefix_len(&self, old: &Self) -> usize { let prefix = common_prefix_len(self.chunks(), old.chunks()); floor_char_boundary(self.chunks(), prefix) } } -fn text_leaves<'a>(block: &'a BlockNode, out: &mut Vec<(TextLeafKey, TextLeaf<'a>)>) { +pub(super) fn text_leaves<'a>(block: &'a BlockNode, out: &mut Vec<(TextLeafKey, TextLeaf<'a>)>) { match block { BlockNode::Paragraph(paragraph) => { if let Some(span) = paragraph.span { diff --git a/crates/component/src/text/mod.rs b/crates/component/src/text/mod.rs index c69c6816be..ac417de50e 100644 --- a/crates/component/src/text/mod.rs +++ b/crates/component/src/text/mod.rs @@ -10,8 +10,9 @@ pub use compat::{ pub use frontmatter::FrontmatterPlugin; pub use gpui_base::text::{ InlineElement, InlineRenderContext, MarkdownBlockParserFn, MarkdownBlockRenderFn, - MarkdownExtensions, MarkdownNode, MarkdownParseContext, MarkdownPlugin, SelectionFormat, - TableData, TextViewMotion, TextViewState, markdown_ast, + MarkdownExtensions, MarkdownNode, MarkdownParseContext, MarkdownPlugin, RangeHighlight, + RangeHighlightError, RenderedText, SelectionFormat, TableData, TextViewMotion, TextViewState, + markdown_ast, }; pub use style::TextViewStyle; diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index 573dfbde4f..d5c1d7d789 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -17,15 +17,16 @@ use gpui_kit::component::{ h_flex, highlighter::Language, input::{ - DocumentRangeSemanticTokensProvider, Editor, EditorState, InputEvent, Rope, RopeExt, - TabSize, + DocumentRangeSemanticTokensProvider, Editor, EditorState, Input, InputEvent, InputState, + Rope, RopeExt, TabSize, }, menu::{DropdownMenu as _, PopupMenuItem}, resizable::{h_resizable, resizable_panel}, status_bar::StatusBar, text::{ InlineElement, InlineRenderContext, MarkdownNode, MarkdownParseContext, MarkdownPlugin, - SelectionFormat, TextView, TextViewState, TextViewStyle, markdown_ast, + RangeHighlight, RenderedText, SelectionFormat, TextView, TextViewState, TextViewStyle, + markdown_ast, }, v_flex, }; @@ -1182,6 +1183,10 @@ pub struct Example { /// Whether copying a selection yields the rendered text or its Markdown /// source. selection_format: SelectionFormat, + find_state: Entity, + /// The preview text the find query was last highlighted in. + searched: Option, + match_count: usize, _subscriptions: Vec, } @@ -1216,11 +1221,21 @@ impl Example { focus_handle.focus(window, cx); }); - let _subscriptions = - vec![cx.subscribe(&input_state, |_, _, _: &InputEvent, cx| cx.notify())]; - let text_view = cx.new(|cx| TextViewState::markdown(EXAMPLE, cx)); let inline_math = InlineMathPlugin::new(&text_view); + let find_state = cx.new(|cx| InputState::new(window, cx).placeholder("Find in preview")); + + let _subscriptions = vec![ + cx.subscribe(&input_state, |_, _, _: &InputEvent, cx| cx.notify()), + cx.subscribe(&find_state, |this, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.searched = None; + this.highlight_matches(cx); + } + }), + // Search again whenever the preview's content changes. + cx.observe(&text_view, |this, _, cx| this.highlight_matches(cx)), + ]; Self { text_view, inline_math, @@ -1229,10 +1244,44 @@ impl Example { // Default to horizontal scrolling for tables. table_wrap: false, selection_format: SelectionFormat::Plain, + find_state, + searched: None, + match_count: 0, _subscriptions, } } + /// Highlight every occurrence of the find query in the preview, unless + /// the preview text it was last highlighted in is still current. + fn highlight_matches(&mut self, cx: &mut Context) { + let text = self.text_view.read(cx).rendered_text(); + if self.searched.as_ref() == Some(&text) { + return; + } + + let query = self.find_state.read(cx).value(); + let color = cx.theme().warning.opacity(0.3); + let highlights = if query.is_empty() { + Vec::new() + } else { + text.as_str() + .match_indices(query.as_str()) + .map(|(start, found)| { + RangeHighlight::new(start..start + found.len()).with_background(color) + }) + .collect() + }; + self.match_count = highlights.len(); + let result = self.text_view.update(cx, |state, cx| { + state.set_range_highlights(&text, highlights, cx) + }); + if let Err(error) = result { + eprintln!("Could not highlight the matches: {error}"); + } + self.searched = Some(text); + cx.notify(); + } + /// Build the markdown style: tables scroll horizontally unless `table_wrap` /// is on, in which case the default wrapping layout is used. fn text_view_style(&self) -> TextViewStyle { @@ -1457,6 +1506,22 @@ impl Render for Example { ) .child( StatusBar::new() + .left( + h_flex() + .gap_2() + .child(Input::new(&self.find_state).xsmall().w(px(200.))) + .when(!self.find_state.read(cx).value().is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(match self.match_count { + 1 => "1 match".to_string(), + count => format!("{count} matches"), + }), + ) + }), + ) .right( Button::new("preview-zoom") .ghost() diff --git a/website/base/text-view.md b/website/base/text-view.md index 6a3d2e6a6e..c6bbe5a904 100644 --- a/website/base/text-view.md +++ b/website/base/text-view.md @@ -245,6 +245,12 @@ append, and Markdown that completes as it streams (`**bo` becoming bold blocks the update reaches are compared, and frames are requested only while something is still fading. Reduced motion skips the fade. +`TextViewState::set_range_highlights` paints backgrounds behind ranges of +`rendered_text()`, the text plain copy produces, so an application can show +its search results or citations without reparsing or restyling the document. +The ranges are painted, not shaped, so they never change layout; see +[Highlight ranges](../component/text-view.md#highlight-ranges) for the rules. + Selection can copy rendered text or Markdown source through `SelectionFormat`. Link routing, code-block actions, table actions, images, and custom Markdown plugins use the same builders as the compatibility API documented on the [gpui-component TextView page](../component/text-view.md). ## Runnable source diff --git a/website/component/text-view.md b/website/component/text-view.md index 435498e17c..23894b92f7 100644 --- a/website/component/text-view.md +++ b/website/component/text-view.md @@ -95,6 +95,68 @@ Pass a `TextViewMotion` through `.motion(...)` to choose the duration or easing yourself, or to reveal each chunk word by word; see [GPUI Base TextView](/base/text-view#retained-state-and-streaming-updates). +### Highlight ranges + +An application that searches a document, or points at a citation inside it, +paints its ranges with `set_range_highlights`. The application owns the +search: it finds its ranges in `rendered_text()`, the text the view shows, and +hands them back with the colors to paint them in, a stronger one for the +current result: + +```rust +use gpui_kit::component::{ + ActiveTheme as _, + text::{RangeHighlight, RangeHighlightError, TextViewState}, +}; + +fn highlight_matches( + state: &mut TextViewState, + query: &str, + current_match: usize, + cx: &mut Context, +) -> Result<(), RangeHighlightError> { + let (color, current_color) = (cx.theme().warning.opacity(0.3), cx.theme().warning); + let text = state.rendered_text(); + let matches = if query.is_empty() { + Vec::new() + } else { + text.as_str().match_indices(query).collect() + }; + let highlights = matches.into_iter().enumerate().map(|(ix, (start, found))| { + RangeHighlight::new(start..start + found.len()) + .with_background(if ix == current_match { current_color } else { color }) + }); + state.set_range_highlights(&text, highlights, cx) +} +``` + +`rendered_text()` is the text plain copy produces: `hello **world**` reads +`hello world`, escapes are resolved, and heading and list markers are left +out. Offsets are UTF-8 byte offsets, so the ranges `str` search returns can be +passed as they are, and a repeated phrase is addressed by where it occurs. The +text is built the first time it is read. + +A highlight is painted behind the text and under the selection, so wrapping, +alignment, syntax colors, links, selection and copy stay as they were. Where +highlights overlap, the later one paints over the earlier. A range that +crosses from one block into the next paints in both. Text that belongs to no +block is left unpainted: the line breaks between blocks, the spaces between +table cells, custom blocks, HTML blocks and inline plugin objects. Only a range +that is reversed, out of bounds or not on a character boundary is rejected, +and the whole set with it. + +When the content changes, a highlight follows its block and stays as far as +the block's text is unchanged. Text appended while streaming, through +`push_str` or `set_text`, keeps the highlights before it, and an edit keeps +those before and after it. After an edit inside a table, the cells in and +after the edited row lose theirs, since a cell is only known by its place in +the table. The view notifies, and `set_range_highlights` rejects text taken +before the change as stale: observe the state, and search the new +`rendered_text()` again. Backgrounds that are part of the text, such as +`` and syntax highlighting, paint over a range highlight (inline code's +background is painted under it), and highlights do not fade in with streamed +text. HTML views do not support range highlights. + ## Touch Selection On a touch screen, a long press selects the word under the finger and keeps diff --git a/website/zh-CN/base/text-view.md b/website/zh-CN/base/text-view.md index 5c549da70d..a246b61e0f 100644 --- a/website/zh-CN/base/text-view.md +++ b/website/zh-CN/base/text-view.md @@ -218,6 +218,8 @@ TextView::new(&document).motion( 不设错位时每次更新整块一起淡入。设了错位时,追加的文字按词拆分(词带上其后的空白),中日韩文字按字拆分;一次追加很长时会压缩错位,保证最后一个词在一个淡入时长内开始。追踪器比较的是渲染后的文字而不是源码字节,因此 `set_text` 传入以当前文本为前缀的更长文本会被视为追加;流式过程中被补齐的 Markdown 标记(`**bo` 变成粗体 `bold`)只让发生变化的字形重新淡入,不会整段闪烁。每次只比较更新触及的块,并且只在还有文字在淡入时才请求下一帧。系统开启减少动态效果时跳过淡入。 +`TextViewState::set_range_highlights` 在 `rendered_text()`(与纯文本复制得到的文字一致)的指定范围后面绘制背景,应用可以借此显示搜索结果或引用位置,无需重新解析或修改文档样式。这些范围只参与绘制、不参与排版,因此不会改变布局;规则详见[高亮文本范围](../component/text-view.md#高亮文本范围)。 + 通过 `SelectionFormat` 可以选择复制渲染文本或 Markdown 源码。链接路由、代码块操作、表格操作、图片和 Markdown 插件继续使用与兼容 API 相同的 builder,详见 [gpui-component TextView 文档](../component/text-view.md)。 ## 可运行源码 diff --git a/website/zh-CN/component/text-view.md b/website/zh-CN/component/text-view.md index 7679a049f7..08c618cde6 100644 --- a/website/zh-CN/component/text-view.md +++ b/website/zh-CN/component/text-view.md @@ -58,6 +58,43 @@ TextView::new(&self.reply).stream_fade(true) 需要自定义时长、缓动,或者让每块按词逐个浮现时,通过 `.motion(...)` 传入 `TextViewMotion`,详见 [GPUI Base TextView](/zh-CN/base/text-view#保留状态与动态更新)。 +### 高亮文本范围 + +应用在文档里搜索,或者指向文档里的某处引用时,用 `set_range_highlights` 把这些范围标出来。搜索由应用负责:在视图显示的文本 `rendered_text()` 里找到范围,连同绘制用的颜色一起交回,当前结果用更醒目的颜色: + +```rust +use gpui_kit::component::{ + ActiveTheme as _, + text::{RangeHighlight, RangeHighlightError, TextViewState}, +}; + +fn highlight_matches( + state: &mut TextViewState, + query: &str, + current_match: usize, + cx: &mut Context, +) -> Result<(), RangeHighlightError> { + let (color, current_color) = (cx.theme().warning.opacity(0.3), cx.theme().warning); + let text = state.rendered_text(); + let matches = if query.is_empty() { + Vec::new() + } else { + text.as_str().match_indices(query).collect() + }; + let highlights = matches.into_iter().enumerate().map(|(ix, (start, found))| { + RangeHighlight::new(start..start + found.len()) + .with_background(if ix == current_match { current_color } else { color }) + }); + state.set_range_highlights(&text, highlights, cx) +} +``` + +`rendered_text()` 与纯文本复制得到的文字一致:`hello **world**` 读作 `hello world`,转义字符已经还原,标题和列表的标记不在其中。偏移量是 UTF-8 字节偏移,`str` 搜索返回的范围可以直接传入;重复出现的词组按各自的位置区分。文本在第一次读取时才会生成。 + +高亮绘制在文字背后、选区之下,换行、对齐、语法颜色、链接、选择和复制都保持不变。多个高亮重叠时,后面的覆盖前面的。跨越两个块的范围在两个块里分别绘制。不属于任何块的文字不会绘制:块之间的换行、表格单元格之间的空格、自定义块、HTML 块和 inline plugin 对象。只有起点在终点之后、超出范围或不在字符边界上的范围会被拒绝,同一批的整组高亮也一并拒绝。 + +内容变化时,高亮会跟随它所在的块,保留到这个块里文字开始变化的位置为止。流式追加的文字(无论通过 `push_str` 还是 `set_text`)不影响前面的高亮;修改某处时,修改前后的高亮都会保留。表格单元格只按位置区分,因此修改表格内部时,被修改的那一行及其后各行单元格的高亮都会失效。视图会发出通知,变化之前取得的文本会被 `set_range_highlights` 视为过期而拒绝:观察这个 state,重新在新的 `rendered_text()` 里搜索即可。属于文字本身的背景(例如 `` 和语法高亮)会覆盖在范围高亮之上(行内代码的背景在高亮之下),高亮也不会随流式文字一起淡入。HTML 视图不支持范围高亮。 + ## 触摸选择 在触摸屏上,长按会选中手指下的单词,手指按住不放时选区跟随手指移动。抬起手指后,选区上方会出现包含 `复制` 和 `全选` 的编辑菜单,并在选区两端各显示一个拖动 handle。拖动 handle 会移动对应的一端,另一端保持不动;`全选` 选中被按下的那个视图,其 handle 仍可继续调整结果。 From fdfb3b4e71e5c8af1ab24cb17ac7422e1e9b4754 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 24 Sep 2026 19:07:49 +0800 Subject: [PATCH 2/6] example-markdown: Hide focus ring on preview find input --- examples/markdown/src/main.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index d5c1d7d789..e9a903ad80 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -1509,7 +1509,12 @@ impl Render for Example { .left( h_flex() .gap_2() - .child(Input::new(&self.find_state).xsmall().w(px(200.))) + .child( + Input::new(&self.find_state) + .xsmall() + .w(px(200.)) + .focus_bordered(false), + ) .when(!self.find_state.read(cx).value().is_empty(), |this| { this.child( div() From b2ccc4c9b46dba2121f74d4349209955d0fe4e28 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 24 Sep 2026 19:15:10 +0800 Subject: [PATCH 3/6] text: Rename rendered text snapshot type --- crates/base/src/lib.rs | 6 +++--- crates/base/src/text/mod.rs | 2 +- crates/base/src/text/range_highlight.rs | 22 +++++++++++----------- crates/base/src/text/state.rs | 8 ++++---- crates/component/src/text/mod.rs | 4 ++-- examples/markdown/src/main.rs | 6 +++--- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/crates/base/src/lib.rs b/crates/base/src/lib.rs index 7cfecf870c..560028416d 100644 --- a/crates/base/src/lib.rs +++ b/crates/base/src/lib.rs @@ -175,9 +175,9 @@ pub use table::{Table, TableBody, TableCaption, TableCell, TableHead, TableHeade pub use tabs::{Tab, TabStyles, Tabs}; pub use text::{ InlineElement, InlineRenderContext, MarkdownExtensions, MarkdownNode, MarkdownParseContext, - MarkdownPlugin, RangeHighlight, RangeHighlightError, RenderedText, SelectionFormat, TableData, - Text, TextView, TextViewDefaults, TextViewMotion, TextViewPlugin, TextViewState, TextViewStyle, - html, markdown, markdown_ast, + MarkdownPlugin, RangeHighlight, RangeHighlightError, RenderedTextSnapshot, SelectionFormat, + TableData, Text, TextView, TextViewDefaults, TextViewMotion, TextViewPlugin, TextViewState, + TextViewStyle, html, markdown, markdown_ast, }; pub use text_selection::{ TextSelection, TextSelectionContentKey, TextSelectionCoverage, TextSelectionEndpoint, diff --git a/crates/base/src/text/mod.rs b/crates/base/src/text/mod.rs index 0332ddedac..3162d4547b 100644 --- a/crates/base/src/text/mod.rs +++ b/crates/base/src/text/mod.rs @@ -21,7 +21,7 @@ use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Window}; pub use inline_element::*; pub use markdown_ext::*; pub use node::{CodeBlock, TableData}; -pub use range_highlight::{RangeHighlight, RangeHighlightError, RenderedText}; +pub use range_highlight::{RangeHighlight, RangeHighlightError, RenderedTextSnapshot}; pub use state::*; pub use stream_fade::TextViewMotion; pub use style::*; diff --git a/crates/base/src/text/range_highlight.rs b/crates/base/src/text/range_highlight.rs index 80f1d7bd48..cf31bf530d 100644 --- a/crates/base/src/text/range_highlight.rs +++ b/crates/base/src/text/range_highlight.rs @@ -24,8 +24,8 @@ use super::{ stream_fade::{TextLeaf, TextLeafKey, text_leaves}, }; -/// The text a [`TextViewState`](super::TextViewState) renders, as of one -/// parse of its content. +/// A snapshot of the text a [`TextViewState`](super::TextViewState) renders, +/// as of one parse of its content. /// /// Offsets into it are UTF-8 byte offsets. It is the string plain copy /// produces: `hello **world**` renders as `hello world`, escapes are @@ -43,23 +43,23 @@ use super::{ /// /// [`rendered_text`]: super::TextViewState::rendered_text #[derive(Clone)] -pub struct RenderedText { +pub struct RenderedTextSnapshot { owner: EntityId, revision: usize, document: ParsedDocument, index: Arc>, } -impl std::fmt::Debug for RenderedText { +impl std::fmt::Debug for RenderedTextSnapshot { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("RenderedText") + f.debug_struct("RenderedTextSnapshot") .field("owner", &self.owner) .field("revision", &self.revision) .finish_non_exhaustive() } } -impl RenderedText { +impl RenderedTextSnapshot { /// The text of `document`, whose index `index` holds once built. pub(super) fn new( owner: EntityId, @@ -104,15 +104,15 @@ impl RenderedText { } } -impl PartialEq for RenderedText { +impl PartialEq for RenderedTextSnapshot { fn eq(&self, other: &Self) -> bool { self.owner == other.owner && self.revision == other.revision } } -impl Eq for RenderedText {} +impl Eq for RenderedTextSnapshot {} -/// A background painted behind one range of a [`RenderedText`]. +/// A background painted behind one range of a [`RenderedTextSnapshot`]. /// /// It is painted under the text and under the selection, and never changes /// layout. Where highlights overlap, the later one paints over the earlier. A @@ -124,7 +124,7 @@ pub struct RangeHighlight { } impl RangeHighlight { - /// A highlight over `range`, in byte offsets of a [`RenderedText`]. + /// A highlight over `range`, in byte offsets of a [`RenderedTextSnapshot`]. pub fn new(range: Range) -> Self { Self { range, @@ -408,7 +408,7 @@ pub(crate) struct RangeHighlightFrame { impl RangeHighlightFrame { /// Validates `highlights` against `text` and resolves them to leaves. pub(super) fn new( - text: &RenderedText, + text: &RenderedTextSnapshot, highlights: impl IntoIterator, ) -> Result, RangeHighlightError> { let mut pieces = Vec::new(); diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index 1116524ec7..7a4957b979 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -22,7 +22,7 @@ use crate::{ input::{self, SelectAll}, text::{ CodeBlockActionsFn, CodeBlockHighlighterFn, LinkClickHandlerFn, MarkdownExtensions, - RangeHighlight, RangeHighlightError, RenderedText, TableActionsFn, TextViewStyle, + RangeHighlight, RangeHighlightError, RenderedTextSnapshot, TableActionsFn, TextViewStyle, document::ParsedDocument, format, node::{self, NodeContext}, @@ -564,8 +564,8 @@ impl TextViewState { /// /// This is the string plain copy produces, as of the last parse that /// landed; text set since then is not in it until its parse lands. - pub fn rendered_text(&self) -> RenderedText { - RenderedText::new( + pub fn rendered_text(&self) -> RenderedTextSnapshot { + RenderedTextSnapshot::new( self.entity_id, self.committed_revision, self.parsed_content.document.clone(), @@ -592,7 +592,7 @@ impl TextViewState { /// text. pub fn set_range_highlights( &mut self, - text: &RenderedText, + text: &RenderedTextSnapshot, highlights: impl IntoIterator, cx: &mut Context, ) -> Result<(), RangeHighlightError> { diff --git a/crates/component/src/text/mod.rs b/crates/component/src/text/mod.rs index ac417de50e..5b03b44059 100644 --- a/crates/component/src/text/mod.rs +++ b/crates/component/src/text/mod.rs @@ -11,8 +11,8 @@ pub use frontmatter::FrontmatterPlugin; pub use gpui_base::text::{ InlineElement, InlineRenderContext, MarkdownBlockParserFn, MarkdownBlockRenderFn, MarkdownExtensions, MarkdownNode, MarkdownParseContext, MarkdownPlugin, RangeHighlight, - RangeHighlightError, RenderedText, SelectionFormat, TableData, TextViewMotion, TextViewState, - markdown_ast, + RangeHighlightError, RenderedTextSnapshot, SelectionFormat, TableData, TextViewMotion, + TextViewState, markdown_ast, }; pub use style::TextViewStyle; diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index e9a903ad80..c986ac8fb8 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -25,8 +25,8 @@ use gpui_kit::component::{ status_bar::StatusBar, text::{ InlineElement, InlineRenderContext, MarkdownNode, MarkdownParseContext, MarkdownPlugin, - RangeHighlight, RenderedText, SelectionFormat, TextView, TextViewState, TextViewStyle, - markdown_ast, + RangeHighlight, RenderedTextSnapshot, SelectionFormat, TextView, TextViewState, + TextViewStyle, markdown_ast, }, v_flex, }; @@ -1185,7 +1185,7 @@ pub struct Example { selection_format: SelectionFormat, find_state: Entity, /// The preview text the find query was last highlighted in. - searched: Option, + searched: Option, match_count: usize, _subscriptions: Vec, } From c7a87892dccff1917e6518ad94614d62e3f3cfbc Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 24 Sep 2026 19:20:02 +0800 Subject: [PATCH 4/6] text: Simplify range highlight API --- crates/base/src/text/range_highlight.rs | 40 ++++++++--------------- crates/base/src/text/state.rs | 42 +++++++++++++++++-------- examples/markdown/src/main.rs | 37 ++++++++++++---------- website/component/text-view.md | 15 +++++---- website/zh-CN/component/text-view.md | 10 +++--- 5 files changed, 77 insertions(+), 67 deletions(-) diff --git a/crates/base/src/text/range_highlight.rs b/crates/base/src/text/range_highlight.rs index cf31bf530d..4042c629fc 100644 --- a/crates/base/src/text/range_highlight.rs +++ b/crates/base/src/text/range_highlight.rs @@ -116,39 +116,31 @@ impl Eq for RenderedTextSnapshot {} /// /// It is painted under the text and under the selection, and never changes /// layout. Where highlights overlap, the later one paints over the earlier. A -/// highlight without a background paints nothing. #[derive(Clone, Debug, PartialEq)] pub struct RangeHighlight { range: Range, - background: Option, + background: Hsla, } impl RangeHighlight { /// A highlight over `range`, in byte offsets of a [`RenderedTextSnapshot`]. - pub fn new(range: Range) -> Self { + pub fn new(range: Range, background: impl Into) -> Self { Self { range, - background: None, + background: background.into(), } } - /// The color painted behind the range. - pub fn with_background(mut self, background: impl Into) -> Self { - self.background = Some(background.into()); - self - } - pub fn range(&self) -> Range { self.range.clone() } - pub fn background(&self) -> Option { + pub fn background(&self) -> Hsla { self.background } } -/// Why [`TextViewState::set_range_highlights`](super::TextViewState::set_range_highlights) -/// rejected a set of highlights. Nothing changes when it does. +/// Why setting range highlights was rejected. Existing highlights stay unchanged. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum RangeHighlightError { @@ -417,13 +409,11 @@ impl RangeHighlightFrame { .index() .resolve(&highlight.range) .ok_or(RangeHighlightError::InvalidRange(ix))?; - if let Some(background) = highlight.background { - pieces.extend( - leaf_ranges - .into_iter() - .map(|(key, range)| (key, range, background)), - ); - } + pieces.extend( + leaf_ranges + .into_iter() + .map(|(key, range)| (key, range, highlight.background)), + ); } // Stable, so each leaf keeps the application's order. @@ -579,14 +569,10 @@ mod tests { use super::RangeHighlight; #[test] - fn test_range_highlight_builder() { - let highlight = RangeHighlight::new(2..5); - assert_eq!(highlight.range(), 2..5); - assert_eq!(highlight.background(), None); - + fn range_highlight_requires_a_background() { let color = hsla(0.15, 1., 0.5, 0.4); - let highlight = highlight.with_background(color); + let highlight = RangeHighlight::new(2..5, color); assert_eq!(highlight.range(), 2..5); - assert_eq!(highlight.background(), Some(color)); + assert_eq!(highlight.background(), color); } } diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index 7a4957b979..89f7398235 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -573,7 +573,21 @@ impl TextViewState { ) } - /// Replace the range highlights, whose ranges index `text`. + /// Replace the range highlights, whose ranges index the current rendered text. + /// + /// Use this when ranges are computed from this state during the same update. + /// For ranges computed earlier, use [`Self::set_range_highlights_for_snapshot`] + /// to reject results from a stale or foreign snapshot. + pub fn set_range_highlights( + &mut self, + highlights: impl IntoIterator, + cx: &mut Context, + ) -> Result<(), RangeHighlightError> { + let text = self.rendered_text(); + self.set_range_highlights_for_snapshot(&text, highlights, cx) + } + + /// Replace the range highlights computed from `text` if it is still current. /// /// A highlight crossing from one block into another is painted in both, /// skipping the separator between them; text outside every block's text @@ -588,9 +602,8 @@ impl TextViewState { /// cells in and after the edited row lose theirs, as a cell is only known /// by its place. Backgrounds that are part of the text, such as ``, /// paint over a highlight; inline code's paints under it. - /// Search the new [`Self::rendered_text`] again to highlight the new - /// text. - pub fn set_range_highlights( + /// Search the new [`Self::rendered_text`] again to highlight the new text. + pub fn set_range_highlights_for_snapshot( &mut self, text: &RenderedTextSnapshot, highlights: impl IntoIterator, @@ -1875,7 +1888,7 @@ mod tests { } fn highlight(range: Range) -> RangeHighlight { - RangeHighlight::new(range).with_background(gpui::hsla(0.15, 1., 0.5, 0.4)) + RangeHighlight::new(range, gpui::hsla(0.15, 1., 0.5, 0.4)) } /// Highlights `ranges` of the current rendered text. @@ -1885,8 +1898,7 @@ mod tests { cx: &mut TestAppContext, ) -> Result<(), RangeHighlightError> { state.update(cx, |state, cx| { - let text = state.rendered_text(); - state.set_range_highlights(&text, ranges.into_iter().map(highlight), cx) + state.set_range_highlights(ranges.into_iter().map(highlight), cx) }) } @@ -2020,7 +2032,7 @@ mod tests { let text = state.read_with(cx, |state, _| state.rendered_text()); other.update(cx, |other, cx| { assert_eq!( - other.set_range_highlights(&text, [highlight(0..3)], cx), + other.set_range_highlights_for_snapshot(&text, [highlight(0..3)], cx), Err(RangeHighlightError::ForeignText) ); }); @@ -2031,7 +2043,7 @@ mod tests { assert_ne!(state.read_with(cx, |state, _| state.rendered_text()), text); state.update(cx, |state, cx| { assert_eq!( - state.set_range_highlights(&text, [highlight(0..3)], cx), + state.set_range_highlights_for_snapshot(&text, [highlight(0..3)], cx), Err(RangeHighlightError::StaleText) ); }); @@ -2041,7 +2053,11 @@ mod tests { html.update(cx, |html, cx| { let text = html.rendered_text(); assert_eq!( - html.set_range_highlights(&text, [highlight(0..3)], cx), + html.set_range_highlights([highlight(0..3)], cx), + Err(RangeHighlightError::Unsupported) + ); + assert_eq!( + html.set_range_highlights_for_snapshot(&text, [highlight(0..3)], cx), Err(RangeHighlightError::Unsupported) ); }); @@ -2089,7 +2105,7 @@ mod tests { assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..2]); state.update(cx, |state, cx| { assert_eq!( - state.set_range_highlights(&text, [highlight(0..5)], cx), + state.set_range_highlights_for_snapshot(&text, [highlight(0..5)], cx), Err(RangeHighlightError::StaleText) ); }); @@ -2254,7 +2270,7 @@ mod tests { let text = state.rendered_text(); let some = text.as_str().find("some").unwrap(); state - .set_range_highlights(&text, [highlight(some..some + 4)], cx) + .set_range_highlights_for_snapshot(&text, [highlight(some..some + 4)], cx) .unwrap(); state.push_str("\n\n[foo]: https://example.com", cx); }); @@ -2326,7 +2342,7 @@ mod tests { state.update(cx, |state, cx| { let rendered = state.rendered_text(); state - .set_range_highlights(&rendered, ranges.map(highlight), cx) + .set_range_highlights_for_snapshot(&rendered, ranges.map(highlight), cx) .unwrap(); }); cx.update(|window, cx| window.draw(cx).clear(cx)); diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index c986ac8fb8..c5fae85bba 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -1254,30 +1254,33 @@ impl Example { /// Highlight every occurrence of the find query in the preview, unless /// the preview text it was last highlighted in is still current. fn highlight_matches(&mut self, cx: &mut Context) { - let text = self.text_view.read(cx).rendered_text(); - if self.searched.as_ref() == Some(&text) { - return; - } - let query = self.find_state.read(cx).value(); let color = cx.theme().warning.opacity(0.3); - let highlights = if query.is_empty() { - Vec::new() - } else { - text.as_str() - .match_indices(query.as_str()) - .map(|(start, found)| { - RangeHighlight::new(start..start + found.len()).with_background(color) - }) - .collect() - }; - self.match_count = highlights.len(); + let searched = self.searched.as_ref(); let result = self.text_view.update(cx, |state, cx| { - state.set_range_highlights(&text, highlights, cx) + let text = state.rendered_text(); + if searched == Some(&text) { + return None; + } + let highlights = if query.is_empty() { + Vec::new() + } else { + text.as_str() + .match_indices(query.as_str()) + .map(|(start, found)| RangeHighlight::new(start..start + found.len(), color)) + .collect() + }; + let count = highlights.len(); + Some((text, count, state.set_range_highlights(highlights, cx))) }); + let Some((text, count, result)) = result else { + return; + }; if let Err(error) = result { eprintln!("Could not highlight the matches: {error}"); + return; } + self.match_count = count; self.searched = Some(text); cx.notify(); } diff --git a/website/component/text-view.md b/website/component/text-view.md index 23894b92f7..5610c2d4bb 100644 --- a/website/component/text-view.md +++ b/website/component/text-view.md @@ -123,10 +123,12 @@ fn highlight_matches( text.as_str().match_indices(query).collect() }; let highlights = matches.into_iter().enumerate().map(|(ix, (start, found))| { - RangeHighlight::new(start..start + found.len()) - .with_background(if ix == current_match { current_color } else { color }) + RangeHighlight::new( + start..start + found.len(), + if ix == current_match { current_color } else { color }, + ) }); - state.set_range_highlights(&text, highlights, cx) + state.set_range_highlights(highlights, cx) } ``` @@ -150,9 +152,10 @@ the block's text is unchanged. Text appended while streaming, through `push_str` or `set_text`, keeps the highlights before it, and an edit keeps those before and after it. After an edit inside a table, the cells in and after the edited row lose theirs, since a cell is only known by its place in -the table. The view notifies, and `set_range_highlights` rejects text taken -before the change as stale: observe the state, and search the new -`rendered_text()` again. Backgrounds that are part of the text, such as +the table. The view notifies when its text changes: observe the state and +search the new `rendered_text()` again. For asynchronous searches, pass the +snapshot used to calculate the ranges to `set_range_highlights_for_snapshot`; +it rejects results from a stale or different view. Backgrounds that are part of the text, such as `` and syntax highlighting, paint over a range highlight (inline code's background is painted under it), and highlights do not fade in with streamed text. HTML views do not support range highlights. diff --git a/website/zh-CN/component/text-view.md b/website/zh-CN/component/text-view.md index 08c618cde6..7883ae21cb 100644 --- a/website/zh-CN/component/text-view.md +++ b/website/zh-CN/component/text-view.md @@ -82,10 +82,12 @@ fn highlight_matches( text.as_str().match_indices(query).collect() }; let highlights = matches.into_iter().enumerate().map(|(ix, (start, found))| { - RangeHighlight::new(start..start + found.len()) - .with_background(if ix == current_match { current_color } else { color }) + RangeHighlight::new( + start..start + found.len(), + if ix == current_match { current_color } else { color }, + ) }); - state.set_range_highlights(&text, highlights, cx) + state.set_range_highlights(highlights, cx) } ``` @@ -93,7 +95,7 @@ fn highlight_matches( 高亮绘制在文字背后、选区之下,换行、对齐、语法颜色、链接、选择和复制都保持不变。多个高亮重叠时,后面的覆盖前面的。跨越两个块的范围在两个块里分别绘制。不属于任何块的文字不会绘制:块之间的换行、表格单元格之间的空格、自定义块、HTML 块和 inline plugin 对象。只有起点在终点之后、超出范围或不在字符边界上的范围会被拒绝,同一批的整组高亮也一并拒绝。 -内容变化时,高亮会跟随它所在的块,保留到这个块里文字开始变化的位置为止。流式追加的文字(无论通过 `push_str` 还是 `set_text`)不影响前面的高亮;修改某处时,修改前后的高亮都会保留。表格单元格只按位置区分,因此修改表格内部时,被修改的那一行及其后各行单元格的高亮都会失效。视图会发出通知,变化之前取得的文本会被 `set_range_highlights` 视为过期而拒绝:观察这个 state,重新在新的 `rendered_text()` 里搜索即可。属于文字本身的背景(例如 `` 和语法高亮)会覆盖在范围高亮之上(行内代码的背景在高亮之下),高亮也不会随流式文字一起淡入。HTML 视图不支持范围高亮。 +内容变化时,高亮会跟随它所在的块,保留到这个块里文字开始变化的位置为止。流式追加的文字(无论通过 `push_str` 还是 `set_text`)不影响前面的高亮;修改某处时,修改前后的高亮都会保留。表格单元格只按位置区分,因此修改表格内部时,被修改的那一行及其后各行单元格的高亮都会失效。视图会发出通知:观察这个 state,重新在新的 `rendered_text()` 里搜索即可。异步搜索应把计算范围时所用的快照交给 `set_range_highlights_for_snapshot`,以拒绝过期或来自其他视图的结果。属于文字本身的背景(例如 `` 和语法高亮)会覆盖在范围高亮之上(行内代码的背景在高亮之下),高亮也不会随流式文字一起淡入。HTML 视图不支持范围高亮。 ## 触摸选择 From 1c2458bf99e9f4f6a16819aed8eff9ebee0198d0 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 24 Sep 2026 19:30:33 +0800 Subject: [PATCH 5/6] text: Remove snapshot highlight setter --- crates/base/src/text/range_highlight.rs | 15 ----- crates/base/src/text/state.rs | 87 +++++-------------------- website/component/text-view.md | 6 +- website/zh-CN/component/text-view.md | 2 +- 4 files changed, 19 insertions(+), 91 deletions(-) diff --git a/crates/base/src/text/range_highlight.rs b/crates/base/src/text/range_highlight.rs index 4042c629fc..f327c420df 100644 --- a/crates/base/src/text/range_highlight.rs +++ b/crates/base/src/text/range_highlight.rs @@ -90,14 +90,6 @@ impl RenderedTextSnapshot { self.index().text.is_empty() } - pub(super) fn owner(&self) -> EntityId { - self.owner - } - - pub(super) fn revision(&self) -> usize { - self.revision - } - pub(super) fn index(&self) -> &RenderedIndex { self.index .get_or_init(|| RenderedIndex::new(&self.document)) @@ -144,11 +136,6 @@ impl RangeHighlight { #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum RangeHighlightError { - /// The text was taken before the view's content last changed; take it - /// again and search the new text. - StaleText, - /// The text was taken from another view. - ForeignText, /// The view renders HTML, which records no source positions to address /// its text by. Unsupported, @@ -160,8 +147,6 @@ pub enum RangeHighlightError { impl std::fmt::Display for RangeHighlightError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::StaleText => f.write_str("the rendered text changed since it was taken"), - Self::ForeignText => f.write_str("the rendered text belongs to another view"), Self::Unsupported => f.write_str("HTML views do not support range highlights"), Self::InvalidRange(ix) => write!(f, "highlight {ix} is not a range of the text"), } diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index 89f7398235..f2d7e9c3ec 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -575,50 +575,27 @@ impl TextViewState { /// Replace the range highlights, whose ranges index the current rendered text. /// - /// Use this when ranges are computed from this state during the same update. - /// For ranges computed earlier, use [`Self::set_range_highlights_for_snapshot`] - /// to reject results from a stale or foreign snapshot. + /// Compute ranges from the current [`Self::rendered_text`] and set them in + /// the same update. Search the new text again after its content changes. + /// A range crossing blocks paints in both, skipping their separator. + /// Text outside every block (separators, custom blocks, HTML blocks, and + /// inline objects) is left unpainted. Any invalid range rejects the set. + /// + /// Highlights follow unchanged text through updates. Text appended while + /// streaming keeps earlier highlights; after a table edit, cells in and + /// after the edited row lose theirs because cells are known by position. + /// Text backgrounds such as `` paint over a highlight; inline code + /// backgrounds paint under it. pub fn set_range_highlights( &mut self, highlights: impl IntoIterator, cx: &mut Context, ) -> Result<(), RangeHighlightError> { let text = self.rendered_text(); - self.set_range_highlights_for_snapshot(&text, highlights, cx) - } - - /// Replace the range highlights computed from `text` if it is still current. - /// - /// A highlight crossing from one block into another is painted in both, - /// skipping the separator between them; text outside every block's text - /// (those separators, custom blocks, HTML blocks, inline objects) is left - /// unpainted. The whole set is rejected when `text` is out of date or any - /// range is not a range of it. - /// - /// When the content changes, a highlight follows its block and stays as - /// far as the block's text is unchanged, and the view notifies: text - /// appended while streaming keeps the highlights before it, and an edit - /// keeps those before and after it. After an edit inside a table, the - /// cells in and after the edited row lose theirs, as a cell is only known - /// by its place. Backgrounds that are part of the text, such as ``, - /// paint over a highlight; inline code's paints under it. - /// Search the new [`Self::rendered_text`] again to highlight the new text. - pub fn set_range_highlights_for_snapshot( - &mut self, - text: &RenderedTextSnapshot, - highlights: impl IntoIterator, - cx: &mut Context, - ) -> Result<(), RangeHighlightError> { if self.format != TextViewFormat::Markdown { return Err(RangeHighlightError::Unsupported); } - if text.owner() != self.entity_id { - return Err(RangeHighlightError::ForeignText); - } - if text.revision() != self.committed_revision { - return Err(RangeHighlightError::StaleText); - } - self.range_highlights = RangeHighlightFrame::new(text, highlights)?.map(Arc::new); + self.range_highlights = RangeHighlightFrame::new(&text, highlights)?.map(Arc::new); cx.notify(); Ok(()) } @@ -2026,40 +2003,14 @@ mod tests { } #[gpui::test] - fn stale_foreign_and_html_text_is_rejected(cx: &mut TestAppContext) { - let state = state("one", cx); - let other = self::state("one", cx); - let text = state.read_with(cx, |state, _| state.rendered_text()); - other.update(cx, |other, cx| { - assert_eq!( - other.set_range_highlights_for_snapshot(&text, [highlight(0..3)], cx), - Err(RangeHighlightError::ForeignText) - ); - }); - assert_eq!(state.read_with(cx, |state, _| state.rendered_text()), text); - assert_ne!(other.read_with(cx, |other, _| other.rendered_text()), text); - state.update(cx, |state, cx| state.set_text("two", cx)); - cx.run_until_parked(); - assert_ne!(state.read_with(cx, |state, _| state.rendered_text()), text); - state.update(cx, |state, cx| { - assert_eq!( - state.set_range_highlights_for_snapshot(&text, [highlight(0..3)], cx), - Err(RangeHighlightError::StaleText) - ); - }); - + fn html_text_is_unsupported(cx: &mut TestAppContext) { let html = cx.update(|cx| cx.new(|cx| TextViewState::html("

one

", cx))); cx.run_until_parked(); html.update(cx, |html, cx| { - let text = html.rendered_text(); assert_eq!( html.set_range_highlights([highlight(0..3)], cx), Err(RangeHighlightError::Unsupported) ); - assert_eq!( - html.set_range_highlights_for_snapshot(&text, [highlight(0..3)], cx), - Err(RangeHighlightError::Unsupported) - ); }); } @@ -2093,7 +2044,6 @@ mod tests { #[gpui::test] fn push_str_keeps_earlier_blocks_and_clips_the_changed_tail(cx: &mut TestAppContext) { let state = state("first\n\na **b", cx); - let text = state.read_with(cx, |state, _| state.rendered_text()); // "first\na **b\n" set(&state, [0..5, 6..11], cx).unwrap(); @@ -2103,12 +2053,6 @@ mod tests { assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]); assert_eq!(painted(&state, TextLeafKey::block(7), cx), [0..2]); - state.update(cx, |state, cx| { - assert_eq!( - state.set_range_highlights_for_snapshot(&text, [highlight(0..5)], cx), - Err(RangeHighlightError::StaleText) - ); - }); } #[gpui::test] @@ -2270,7 +2214,7 @@ mod tests { let text = state.rendered_text(); let some = text.as_str().find("some").unwrap(); state - .set_range_highlights_for_snapshot(&text, [highlight(some..some + 4)], cx) + .set_range_highlights([highlight(some..some + 4)], cx) .unwrap(); state.push_str("\n\n[foo]: https://example.com", cx); }); @@ -2340,9 +2284,8 @@ mod tests { .chain([0..len]) .filter(|range| !text[range.clone()].trim().is_empty()); state.update(cx, |state, cx| { - let rendered = state.rendered_text(); state - .set_range_highlights_for_snapshot(&rendered, ranges.map(highlight), cx) + .set_range_highlights(ranges.map(highlight), cx) .unwrap(); }); cx.update(|window, cx| window.draw(cx).clear(cx)); diff --git a/website/component/text-view.md b/website/component/text-view.md index 5610c2d4bb..cd9bd13a33 100644 --- a/website/component/text-view.md +++ b/website/component/text-view.md @@ -153,9 +153,9 @@ the block's text is unchanged. Text appended while streaming, through those before and after it. After an edit inside a table, the cells in and after the edited row lose theirs, since a cell is only known by its place in the table. The view notifies when its text changes: observe the state and -search the new `rendered_text()` again. For asynchronous searches, pass the -snapshot used to calculate the ranges to `set_range_highlights_for_snapshot`; -it rejects results from a stale or different view. Backgrounds that are part of the text, such as +search the new `rendered_text()` again. Compute ranges and call +`set_range_highlights` in the same state update so the ranges address the +current text. Backgrounds that are part of the text, such as `` and syntax highlighting, paint over a range highlight (inline code's background is painted under it), and highlights do not fade in with streamed text. HTML views do not support range highlights. diff --git a/website/zh-CN/component/text-view.md b/website/zh-CN/component/text-view.md index 7883ae21cb..5c40abb9b5 100644 --- a/website/zh-CN/component/text-view.md +++ b/website/zh-CN/component/text-view.md @@ -95,7 +95,7 @@ fn highlight_matches( 高亮绘制在文字背后、选区之下,换行、对齐、语法颜色、链接、选择和复制都保持不变。多个高亮重叠时,后面的覆盖前面的。跨越两个块的范围在两个块里分别绘制。不属于任何块的文字不会绘制:块之间的换行、表格单元格之间的空格、自定义块、HTML 块和 inline plugin 对象。只有起点在终点之后、超出范围或不在字符边界上的范围会被拒绝,同一批的整组高亮也一并拒绝。 -内容变化时,高亮会跟随它所在的块,保留到这个块里文字开始变化的位置为止。流式追加的文字(无论通过 `push_str` 还是 `set_text`)不影响前面的高亮;修改某处时,修改前后的高亮都会保留。表格单元格只按位置区分,因此修改表格内部时,被修改的那一行及其后各行单元格的高亮都会失效。视图会发出通知:观察这个 state,重新在新的 `rendered_text()` 里搜索即可。异步搜索应把计算范围时所用的快照交给 `set_range_highlights_for_snapshot`,以拒绝过期或来自其他视图的结果。属于文字本身的背景(例如 `` 和语法高亮)会覆盖在范围高亮之上(行内代码的背景在高亮之下),高亮也不会随流式文字一起淡入。HTML 视图不支持范围高亮。 +内容变化时,高亮会跟随它所在的块,保留到这个块里文字开始变化的位置为止。流式追加的文字(无论通过 `push_str` 还是 `set_text`)不影响前面的高亮;修改某处时,修改前后的高亮都会保留。表格单元格只按位置区分,因此修改表格内部时,被修改的那一行及其后各行单元格的高亮都会失效。视图会发出通知:观察这个 state,重新在新的 `rendered_text()` 里搜索即可。请在同一次 state 更新中计算范围并调用 `set_range_highlights`,确保范围对应当前文本。属于文字本身的背景(例如 `` 和语法高亮)会覆盖在范围高亮之上(行内代码的背景在高亮之下),高亮也不会随流式文字一起淡入。HTML 视图不支持范围高亮。 ## 触摸选择 From 1bec172305c2855b1edd3a6c153f364a0eb4d34e Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 24 Sep 2026 19:36:45 +0800 Subject: [PATCH 6/6] text: Rename rendered text snapshot type --- crates/base/src/lib.rs | 6 +++--- crates/base/src/text/mod.rs | 2 +- crates/base/src/text/range_highlight.rs | 18 +++++++++--------- crates/base/src/text/state.rs | 6 +++--- crates/component/src/text/mod.rs | 4 ++-- examples/markdown/src/main.rs | 6 +++--- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/base/src/lib.rs b/crates/base/src/lib.rs index 560028416d..7cfecf870c 100644 --- a/crates/base/src/lib.rs +++ b/crates/base/src/lib.rs @@ -175,9 +175,9 @@ pub use table::{Table, TableBody, TableCaption, TableCell, TableHead, TableHeade pub use tabs::{Tab, TabStyles, Tabs}; pub use text::{ InlineElement, InlineRenderContext, MarkdownExtensions, MarkdownNode, MarkdownParseContext, - MarkdownPlugin, RangeHighlight, RangeHighlightError, RenderedTextSnapshot, SelectionFormat, - TableData, Text, TextView, TextViewDefaults, TextViewMotion, TextViewPlugin, TextViewState, - TextViewStyle, html, markdown, markdown_ast, + MarkdownPlugin, RangeHighlight, RangeHighlightError, RenderedText, SelectionFormat, TableData, + Text, TextView, TextViewDefaults, TextViewMotion, TextViewPlugin, TextViewState, TextViewStyle, + html, markdown, markdown_ast, }; pub use text_selection::{ TextSelection, TextSelectionContentKey, TextSelectionCoverage, TextSelectionEndpoint, diff --git a/crates/base/src/text/mod.rs b/crates/base/src/text/mod.rs index 3162d4547b..0332ddedac 100644 --- a/crates/base/src/text/mod.rs +++ b/crates/base/src/text/mod.rs @@ -21,7 +21,7 @@ use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Window}; pub use inline_element::*; pub use markdown_ext::*; pub use node::{CodeBlock, TableData}; -pub use range_highlight::{RangeHighlight, RangeHighlightError, RenderedTextSnapshot}; +pub use range_highlight::{RangeHighlight, RangeHighlightError, RenderedText}; pub use state::*; pub use stream_fade::TextViewMotion; pub use style::*; diff --git a/crates/base/src/text/range_highlight.rs b/crates/base/src/text/range_highlight.rs index f327c420df..0533b0d260 100644 --- a/crates/base/src/text/range_highlight.rs +++ b/crates/base/src/text/range_highlight.rs @@ -43,23 +43,23 @@ use super::{ /// /// [`rendered_text`]: super::TextViewState::rendered_text #[derive(Clone)] -pub struct RenderedTextSnapshot { +pub struct RenderedText { owner: EntityId, revision: usize, document: ParsedDocument, index: Arc>, } -impl std::fmt::Debug for RenderedTextSnapshot { +impl std::fmt::Debug for RenderedText { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("RenderedTextSnapshot") + f.debug_struct("RenderedText") .field("owner", &self.owner) .field("revision", &self.revision) .finish_non_exhaustive() } } -impl RenderedTextSnapshot { +impl RenderedText { /// The text of `document`, whose index `index` holds once built. pub(super) fn new( owner: EntityId, @@ -96,15 +96,15 @@ impl RenderedTextSnapshot { } } -impl PartialEq for RenderedTextSnapshot { +impl PartialEq for RenderedText { fn eq(&self, other: &Self) -> bool { self.owner == other.owner && self.revision == other.revision } } -impl Eq for RenderedTextSnapshot {} +impl Eq for RenderedText {} -/// A background painted behind one range of a [`RenderedTextSnapshot`]. +/// A background painted behind one range of a [`RenderedText`]. /// /// It is painted under the text and under the selection, and never changes /// layout. Where highlights overlap, the later one paints over the earlier. A @@ -115,7 +115,7 @@ pub struct RangeHighlight { } impl RangeHighlight { - /// A highlight over `range`, in byte offsets of a [`RenderedTextSnapshot`]. + /// A highlight over `range`, in byte offsets of a [`RenderedText`]. pub fn new(range: Range, background: impl Into) -> Self { Self { range, @@ -385,7 +385,7 @@ pub(crate) struct RangeHighlightFrame { impl RangeHighlightFrame { /// Validates `highlights` against `text` and resolves them to leaves. pub(super) fn new( - text: &RenderedTextSnapshot, + text: &RenderedText, highlights: impl IntoIterator, ) -> Result, RangeHighlightError> { let mut pieces = Vec::new(); diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index f2d7e9c3ec..a7f9f576c4 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -22,7 +22,7 @@ use crate::{ input::{self, SelectAll}, text::{ CodeBlockActionsFn, CodeBlockHighlighterFn, LinkClickHandlerFn, MarkdownExtensions, - RangeHighlight, RangeHighlightError, RenderedTextSnapshot, TableActionsFn, TextViewStyle, + RangeHighlight, RangeHighlightError, RenderedText, TableActionsFn, TextViewStyle, document::ParsedDocument, format, node::{self, NodeContext}, @@ -564,8 +564,8 @@ impl TextViewState { /// /// This is the string plain copy produces, as of the last parse that /// landed; text set since then is not in it until its parse lands. - pub fn rendered_text(&self) -> RenderedTextSnapshot { - RenderedTextSnapshot::new( + pub fn rendered_text(&self) -> RenderedText { + RenderedText::new( self.entity_id, self.committed_revision, self.parsed_content.document.clone(), diff --git a/crates/component/src/text/mod.rs b/crates/component/src/text/mod.rs index 5b03b44059..ac417de50e 100644 --- a/crates/component/src/text/mod.rs +++ b/crates/component/src/text/mod.rs @@ -11,8 +11,8 @@ pub use frontmatter::FrontmatterPlugin; pub use gpui_base::text::{ InlineElement, InlineRenderContext, MarkdownBlockParserFn, MarkdownBlockRenderFn, MarkdownExtensions, MarkdownNode, MarkdownParseContext, MarkdownPlugin, RangeHighlight, - RangeHighlightError, RenderedTextSnapshot, SelectionFormat, TableData, TextViewMotion, - TextViewState, markdown_ast, + RangeHighlightError, RenderedText, SelectionFormat, TableData, TextViewMotion, TextViewState, + markdown_ast, }; pub use style::TextViewStyle; diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index c5fae85bba..7543d00b63 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -25,8 +25,8 @@ use gpui_kit::component::{ status_bar::StatusBar, text::{ InlineElement, InlineRenderContext, MarkdownNode, MarkdownParseContext, MarkdownPlugin, - RangeHighlight, RenderedTextSnapshot, SelectionFormat, TextView, TextViewState, - TextViewStyle, markdown_ast, + RangeHighlight, RenderedText, SelectionFormat, TextView, TextViewState, TextViewStyle, + markdown_ast, }, v_flex, }; @@ -1185,7 +1185,7 @@ pub struct Example { selection_format: SelectionFormat, find_state: Entity, /// The preview text the find query was last highlighted in. - searched: Option, + searched: Option, match_count: usize, _subscriptions: Vec, }