diff --git a/crates/base/src/text/inline.rs b/crates/base/src/text/inline.rs index c1ff12bf75..664791e756 100644 --- a/crates/base/src/text/inline.rs +++ b/crates/base/src/text/inline.rs @@ -21,6 +21,7 @@ use crate::{ input::Selection, text::TextViewMultiClickKind, text::node::LinkMark, + text::range_highlight::RevealAt, text::selection::word_range_at, text::state::LineSpan, text::text_view::{LinkClickHandlerFn, handle_link_click}, @@ -206,6 +207,8 @@ pub(super) struct Inline { selection_source: Option<(Arc>, Range)>, /// Range highlight backgrounds, painted behind the text. range_backgrounds: Vec<(Range, Hsla)>, + /// The start of a pending reveal, when it is in this text. + reveal: Option, link_click_handler: Option>, /// What this frame's layout was shaped with, to hand the shaped text to /// the next frame (see [`RetainedLayout`]). @@ -367,6 +370,7 @@ impl Inline { selection_bounds: None, selection_source: None, range_backgrounds: Vec::new(), + reveal: None, link_click_handler, retained_key: None, handed_over: false, @@ -407,6 +411,61 @@ impl Inline { self } + /// Scroll the line `reveal` starts on into view during prepaint. + pub(super) fn reveal(mut self, reveal: Option) -> Self { + self.reveal = reveal; + self + } + + /// Ask the enclosing list to scroll the line of the pending reveal into + /// view, and report where it is and whether it is inside the visible + /// area. + fn request_reveal(&self, window: &mut Window) { + let Some(reveal) = &self.reveal else { + return; + }; + let text_layout = self.styled_text.layout(); + let bounds = text_layout.bounds(); + let line_height = text_layout.line_height(); + let glyphs = glyph_boxes( + text_layout, + window.text_style().text_align, + bounds.size.width, + ); + // The glyph drawing the text at the offset, or, for text with no + // glyph of its own such as a line break, the next glyph, or the last. + let offset = reveal.offset(); + let (row, left, right) = range_boxes(&glyphs, offset..offset + 1) + .first() + .copied() + .or_else(|| { + glyphs + .iter() + .find(|glyph| glyph.text.start >= offset) + .or(glyphs.last()) + .map(|glyph| (glyph.row, glyph.left, glyph.right)) + }) + .unwrap_or((0, Pixels::ZERO, Pixels::ZERO)); + let line = Bounds::from_corners( + point( + bounds.left() + left, + bounds.top() + line_height * row as f32, + ), + point( + bounds.left() + right.max(left + px(1.)), + bounds.top() + line_height * (row + 1) as f32, + ), + ); + window.request_autoscroll(line); + // A list scrolls the line to its edge, which layout may miss by a + // fraction of a pixel. + let visible = window.content_mask().bounds.dilate(px(0.5)); + reveal.report( + line, + line.top() >= visible.top() && line.bottom() <= visible.bottom(), + ); + } + /// Get link at given mouse position. fn link_for_position( layout: &TextLayout, @@ -826,6 +885,8 @@ impl Element for Inline { } } + self.request_reveal(window); + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); self.retain_styled_text(); hitbox diff --git a/crates/base/src/text/inline_flow.rs b/crates/base/src/text/inline_flow.rs index c2e62c8a83..6631ccf4b1 100644 --- a/crates/base/src/text/inline_flow.rs +++ b/crates/base/src/text/inline_flow.rs @@ -20,6 +20,7 @@ use super::{ inline::{Inline, InlineHighlight, InlineState, text_runs, text_size_ranges}, inline_object::{InlineObject, MeasuredInlineObject}, node::LinkMark, + range_highlight::RevealAt, }; const IMAGE_LEN: usize = 1; @@ -53,6 +54,8 @@ pub(super) enum InlineFlowItem { /// 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)>, + /// The start of a pending reveal, when it is in this item. + reveal: Option, }, Image { source: ImageSource, @@ -432,8 +435,36 @@ impl Element for InlineFlow { let text_style = &typography.text_style; let mut elements = Vec::with_capacity(layout.fragments.len()); + // A reveal goes to the fragment it starts in. A line break lays out + // no fragment, so a reveal starting on one, or on an empty line, goes + // to the next fragment of its text, or to the last when none follows. + let reveal_fragment = layout + .fragments + .iter() + .enumerate() + .filter_map(|(ix, fragment)| { + let PositionedFragment::Text { + item_ix, + source_range, + .. + } = fragment + else { + return None; + }; + let InlineFlowItem::Text { + reveal: Some(reveal), + .. + } = &self.items[*item_ix] + else { + return None; + }; + Some((ix, source_range.end > reveal.offset())) + }) + .reduce(|found, next| if found.1 { found } else { next }) + .map(|(ix, _)| ix); + let mut text_fragment_count = 0; - for fragment in &layout.fragments { + for (fragment_ix, fragment) in layout.fragments.iter().enumerate() { match fragment { PositionedFragment::Object { item_ix, @@ -496,6 +527,7 @@ impl Element for InlineFlow { let InlineFlowItem::Text { state: source_state, backgrounds, + reveal, .. } = &self.items[*item_ix] else { @@ -540,6 +572,12 @@ impl Element for InlineFlow { source_range.end, |range, color| (range, *color), )) + .reveal( + reveal + .as_ref() + .filter(|_| reveal_fragment == Some(fragment_ix)) + .map(|reveal| reveal.clamp(source_range.start, source_range.end)), + ) .text_style(fragment_style.clone()) .selection_bounds(Bounds::new( point(bounds.left(), bounds.top() + selection_bounds.top()), diff --git a/crates/base/src/text/node.rs b/crates/base/src/text/node.rs index 392570e211..8b0d00eba1 100644 --- a/crates/base/src/text/node.rs +++ b/crates/base/src/text/node.rs @@ -25,7 +25,7 @@ use crate::{ text_size_ranges, }, inline_flow::{InlineFlow, InlineFlowItem, slice_ranges}, - range_highlight::RangeHighlightFrame, + range_highlight::{RangeHighlightFrame, RevealAt, RevealRequest}, stream_fade::{StreamFadeFrame, TextLeafKey}, text_view::handle_link_click, }, @@ -1891,7 +1891,8 @@ impl CodeBlock { ), node_cx.link_click_handler.clone(), ) - .range_backgrounds(node_cx.range_backgrounds(leaf_key).to_vec()), + .range_backgrounds(node_cx.range_backgrounds(leaf_key).to_vec()) + .reveal(node_cx.reveal_at(leaf_key, 0, self.code().len())), ); // The id scopes the caller's action ids per code block, so plain ids // like `"copy"` don't collide across blocks; without actions nothing @@ -1941,6 +1942,8 @@ pub(crate) struct NodeContext { pub(crate) stream_fade: Option>, /// The application's range highlights, when there are any. pub(crate) range_highlights: Option>, + /// The line being scrolled into view, when there is one. + pub(crate) reveal: Option, } impl NodeContext { @@ -1964,6 +1967,12 @@ impl NodeContext { _ => &[], } } + + /// The pending reveal, when it starts in the text leaf `key` between + /// `start` and `end`, rebased to `start`. + fn reveal_at(&self, key: Option, start: usize, end: usize) -> Option { + self.reveal.as_ref()?.at(key, start, end) + } } impl PartialEq for NodeContext { @@ -2056,7 +2065,7 @@ impl Paragraph { if self.should_render_inline_flow() { return InlineFlow::new( leaf_element_id(fade_key), - self.inline_flow_items(fades, backgrounds, node_cx, cx), + self.inline_flow_items(fade_key, fades, backgrounds, node_cx, cx), node_cx.link_click_handler.clone(), ) .into_any_element(); @@ -2079,6 +2088,7 @@ impl Paragraph { } let highlights = fade_highlights(highlights, &slice_fades(fades, 0, text.len())); let backgrounds = slice_backgrounds(backgrounds, 0, text.len()); + let reveal = node_cx.reveal_at(fade_key, 0, text.len()); if let Ok(mut state) = self.state.lock() { state.set_text(text); } @@ -2089,6 +2099,7 @@ impl Paragraph { node_cx.link_click_handler.clone(), ) .range_backgrounds(backgrounds) + .reveal(reveal) .into_any_element(); } @@ -2126,6 +2137,7 @@ impl Paragraph { consumed, consumed + text.len(), )) + .reveal(node_cx.reveal_at(fade_key, consumed, consumed + text.len())) .into_any_element(), ); } @@ -2218,6 +2230,7 @@ impl Paragraph { node_cx.link_click_handler.clone(), ) .range_backgrounds(slice_backgrounds(backgrounds, consumed, text_end)) + .reveal(node_cx.reveal_at(fade_key, consumed, text_end)) .into_any_element(), ); } @@ -2250,6 +2263,7 @@ impl Paragraph { fn inline_flow_items( &self, + leaf_key: Option, fades: &[(Range, f32)], backgrounds: &[(Range, Hsla)], node_cx: &NodeContext, @@ -2273,6 +2287,7 @@ impl Paragraph { let item_fades = slice_fades(fades, consumed, consumed + text.len()); let item_backgrounds = slice_backgrounds(backgrounds, consumed, consumed + text.len()); + let item_reveal = node_cx.reveal_at(leaf_key, consumed, consumed + text.len()); consumed += text.len(); items.push(InlineFlowItem::Text { state: inline_node.state.clone(), @@ -2280,6 +2295,7 @@ impl Paragraph { links: std::mem::take(&mut links), highlights: fade_highlights(std::mem::take(&mut highlights), &item_fades), backgrounds: item_backgrounds, + reveal: item_reveal, }); } let mut object_style = HighlightStyle::default(); @@ -2339,6 +2355,7 @@ impl Paragraph { consumed, consumed + text.len(), ), + reveal: node_cx.reveal_at(leaf_key, consumed, consumed + text.len()), }); } @@ -2394,12 +2411,14 @@ impl Paragraph { &slice_fades(fades, consumed, consumed + text.len()), ); let backgrounds = slice_backgrounds(backgrounds, consumed, consumed + text.len()); + let reveal = node_cx.reveal_at(leaf_key, consumed, consumed + text.len()); items.push(InlineFlowItem::Text { state: self.state.clone(), text: text.into(), links, highlights, backgrounds, + reveal, }); } @@ -2481,7 +2500,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(None, &[], &[], node_cx, cx); let width = super::inline_flow::intrinsic_width(&items, window, cx); let border = if ix + 1 < col_count { CELL_BORDER_PX @@ -3461,7 +3480,7 @@ mod tests { ], ..Default::default() }; - let items = paragraph.inline_flow_items(&[], &[], &node_cx, cx); + let items = paragraph.inline_flow_items(None, &[], &[], &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 index 0533b0d260..acf9e60c52 100644 --- a/crates/base/src/text/range_highlight.rs +++ b/crates/base/src/text/range_highlight.rs @@ -1,4 +1,5 @@ -//! Application-supplied highlights over the text a [`TextViewState`] renders. +//! Application-supplied highlights over the text a [`TextViewState`] renders, +//! and scrolling one of its ranges into view. //! //! Ranges address the rendered text, the string plain copy produces: an //! application searches [`TextViewState::rendered_text`] and hands the ranges @@ -11,12 +12,17 @@ //! [`TextViewState`]: super::TextViewState //! [`TextViewState::rendered_text`]: super::TextViewState::rendered_text +#[cfg(not(target_family = "wasm"))] +use std::time::Instant; use std::{ ops::Range, - sync::{Arc, OnceLock}, + sync::{Arc, Mutex, OnceLock}, + time::Duration, }; +#[cfg(target_family = "wasm")] +use web_time::Instant; -use gpui::{EntityId, Hsla, SharedString}; +use gpui::{Bounds, EntityId, Hsla, Pixels, SharedString}; use super::{ document::ParsedDocument, @@ -107,7 +113,7 @@ 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 +/// layout. Where highlights overlap, the later one paints over the earlier. #[derive(Clone, Debug, PartialEq)] pub struct RangeHighlight { range: Range, @@ -132,23 +138,24 @@ impl RangeHighlight { } } -/// Why setting range highlights was rejected. Existing highlights stay unchanged. +/// Why setting range highlights or revealing a range was rejected. Existing +/// highlights and reveals stay unchanged. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum RangeHighlightError { /// 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. + /// The range at this index, the highlight's or the one revealed, 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::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"), + Self::Unsupported => f.write_str("HTML views do not support ranges of their text"), + Self::InvalidRange(ix) => write!(f, "range {ix} is not a range of the text"), } } } @@ -162,6 +169,8 @@ pub(super) struct RenderedIndex { text: SharedString, /// In document order, so by their position in `text`. leaves: Vec, + /// Where the text of each top-level block sits, in document order. + blocks: Vec>, } #[derive(Debug)] @@ -174,15 +183,40 @@ struct LeafSpan { objects: Vec>, } +impl LeafSpan { + /// `offset` in the leaf's text, moved out of an inline object onto the + /// text after it, or before it at the end of the leaf. `None` when the + /// leaf has no text outside its objects. + fn text_offset_near(&self, offset: usize) -> Option { + let object_at = |offset: usize| self.objects.iter().find(|object| object.contains(&offset)); + let mut after = offset; + while let Some(object) = object_at(after) { + after = object.end; + } + if after < self.range.len() { + return Some(after); + } + let mut before = offset; + while let Some(object) = object_at(before) { + before = object.start.checked_sub(1)?; + } + Some(before) + } +} + impl RenderedIndex { pub(super) fn new(document: &ParsedDocument) -> Self { let mut builder = IndexBuilder::default(); + let mut blocks = Vec::with_capacity(document.blocks.len()); for block in document.blocks.iter() { + let start = builder.text.len(); builder.push_block(block); + blocks.push(start..builder.text.len()); } let index = Self { text: builder.text.into(), leaves: builder.leaves, + blocks, }; debug_assert_eq!(index.text.as_ref(), document.text()); index @@ -227,6 +261,52 @@ impl RenderedIndex { } Some(pieces) } + + /// Where `range` starts: the line of the first leaf text it covers, or, + /// when it covers none, as an empty range does, of the leaf text at its + /// start or last before it in its top-level block, or else that whole + /// block. `None` when it is not a range of the text, or there is none. + fn locate(&self, range: &Range) -> Option { + if let Some((key, leaf_range)) = self.resolve(range)?.into_iter().next() { + return Some(RevealTarget::Line { + key, + offset: leaf_range.start, + }); + } + let block_ix = self + .blocks + .partition_point(|block| block.end <= range.start) + .min(self.blocks.len().checked_sub(1)?); + let block_start = self.blocks[block_ix].start; + let ix = self + .leaves + .partition_point(|leaf| leaf.range.end <= range.start); + let leaf_offset = match self.leaves.get(ix) { + Some(leaf) if leaf.range.contains(&range.start) => { + Some((leaf, range.start - leaf.range.start)) + } + // A position after the text of a leaf, on the separators after + // it or at the end of the text, is on the line of the last + // character before it in its block. + _ => ix + .checked_sub(1) + .and_then(|ix| self.leaves.get(ix)) + .filter(|leaf| leaf.range.start >= block_start) + .and_then(|leaf| { + let (last, _) = self.text[leaf.range.clone()].char_indices().last()?; + Some((leaf, last)) + }), + }; + if let Some((leaf, offset)) = leaf_offset + && let Some(offset) = leaf.text_offset_near(offset) + { + return Some(RevealTarget::Line { + key: leaf.key, + offset, + }); + } + Some(RevealTarget::Block { ix: block_ix }) + } } /// Builds the rendered text the way `BlockNode::text` does, recording each @@ -420,23 +500,53 @@ impl RangeHighlightFrame { .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 + /// The highlights that still describe `new`, the document `remap` maps + /// the old one to: each follows its leaf as far as the leaf's text is /// unchanged, and is dropped with a leaf that is gone. - /// + pub(super) fn remap(&self, remap: &LeafRemap) -> Option { + let mut leaves = self + .leaves + .iter() + .filter_map(|(key, backgrounds)| { + let (new_key, unchanged) = remap.leaf(*key)?; + let clipped = backgrounds + .iter() + .filter(|(range, _)| range.start < unchanged) + .map(|(range, background)| (range.start..range.end.min(unchanged), *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 }) + } +} + +/// Where the text leaves of one parsed document are found in the document +/// parsed after it. +/// +/// A block that starts before the first change of the source is found at the +/// same offset, and one after the last change at an offset moved by the +/// change in length; a block that starts between them is gone. A leaf keeps +/// its text up to where it first differs from before. +pub(super) struct LeafRemap<'a> { + old: &'a ParsedDocument, + old_len: usize, + new_len: usize, + /// With an append, where the block it parsed again starts: every leaf + /// before it is unchanged. + tail_start: Option, + unchanged_prefix: usize, + unchanged_suffix: usize, + old_leaves: Vec<(TextLeafKey, TextLeaf<'a>)>, + new_leaves: Vec<(TextLeafKey, TextLeaf<'a>)>, +} + +impl<'a> LeafRemap<'a> { /// 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 { + pub(super) fn new(old: &'a ParsedDocument, new: &'a ParsedDocument, tail_only: bool) -> Self { 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. @@ -474,16 +584,6 @@ impl RangeHighlightFrame { (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, @@ -501,49 +601,63 @@ impl RangeHighlightFrame { 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) + + Self { + old, + old_len, + new_len, + tail_start, + unchanged_prefix, + unchanged_suffix, + old_leaves: leaves_from(old, tail_start), + new_leaves: leaves_from(new, tail_start), } - 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 }) + /// Where leaf `key` is in the new document, and how much of its text is + /// unchanged, or `None` when it is gone. + pub(super) fn leaf(&self, key: TextLeafKey) -> Option<(TextLeafKey, usize)> { + if self + .tail_start + .is_some_and(|tail_start| key.block_start() < tail_start) + { + return Some((key, usize::MAX)); + } + let new_key = key.moved_to(self.moved(key.block_start())?); + let old_leaf = Self::find(&self.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() < self.unchanged_prefix + && self.tail_start.is_none() + && row_source_end(&self.old.blocks, key.block_start(), cell_ix) + .is_none_or(|end| end > self.unchanged_prefix) + { + return None; + } + let new_leaf = Self::find(&self.new_leaves, new_key)?; + Some((new_key, new_leaf.common_prefix_len(old_leaf))) + } + + /// Where the block starting at `start` in the old document starts in the + /// new one. + fn moved(&self, start: usize) -> Option { + if start < self.unchanged_prefix { + Some(start) + } else if start >= self.old_len - self.unchanged_suffix { + Some(start + self.new_len - self.old_len) + } else { + None + } + } + + fn find<'b>( + leaves: &'b [(TextLeafKey, TextLeaf<'a>)], + key: TextLeafKey, + ) -> Option<&'b TextLeaf<'a>> { + let ix = leaves.binary_search_by_key(&key, |(leaf, _)| *leaf).ok()?; + Some(&leaves[ix].1) } } @@ -553,6 +667,23 @@ mod tests { use super::RangeHighlight; + #[test] + fn a_position_in_an_inline_object_moves_onto_text() { + use super::{LeafSpan, TextLeafKey}; + // "ab" then two objects of 2 bytes each, then "cd". + let leaf = |len: usize| LeafSpan { + range: 10..10 + len, + key: TextLeafKey::block(0), + objects: vec![2..4, 4..6], + }; + assert_eq!(leaf(8).text_offset_near(1), Some(1)); + // Onto the text after the objects. + assert_eq!(leaf(8).text_offset_near(3), Some(6)); + assert_eq!(leaf(8).text_offset_near(5), Some(6)); + // At the end of the leaf, onto the text before them. + assert_eq!(leaf(6).text_offset_near(5), Some(1)); + } + #[test] fn range_highlight_requires_a_background() { let color = hsla(0.15, 1., 0.5, 0.4); @@ -561,3 +692,207 @@ mod tests { assert_eq!(highlight.background(), color); } } + +/// Where a range to reveal starts. +#[derive(Clone, Copy, Debug, PartialEq)] +enum RevealTarget { + /// A line of a text leaf: the leaf, and the offset in its text. + Line { key: TextLeafKey, offset: usize }, + /// A whole top-level block, for text that belongs to no leaf. + Block { ix: usize }, +} + +/// How long a reveal keeps trying. One that has not been carried out by +/// then, e.g. because its view was not painted, is dropped rather than +/// scrolling long after it was asked for. +const REVEAL_TIMEOUT: Duration = Duration::from_secs(1); + +/// How many frames a reveal whose line was laid out but not visible keeps +/// trying, e.g. while an enclosing container scrolls to it. +const REVEAL_ATTEMPTS: usize = 8; + +/// Where the line a reveal starts on was laid out in one frame, in window +/// coordinates, and whether it was inside the visible area. +#[derive(Clone, Copy, Debug)] +struct RevealReport { + line: Bounds, + visible: bool, +} + +/// A range [`TextViewState::reveal_range`](super::TextViewState::reveal_range) +/// is scrolling into view. +/// +/// The `Inline` that lays out the start of the range asks the enclosing list +/// to scroll its line into view during prepaint, and reports where the line +/// ended up. The view reads the report once painted, after any list has +/// scrolled, and is done once the line is visible. +#[derive(Debug)] +pub(super) struct PendingReveal { + target: RevealTarget, + requested_at: Instant, + /// What the target's `Inline` reported this frame; `None` when it was not + /// laid out. + report: Arc>>, + attempts: usize, +} + +/// How a pending reveal went in one frame. +pub(super) enum RevealProgress { + /// The line is visible, so the reveal is done. + Shown, + /// The line was laid out at these window bounds without being visible. + Hidden(Bounds), + /// The line was not laid out. + NotLaidOut, +} + +impl PendingReveal { + /// The start of `range` in `text`, asked for at `now`, or `None` when + /// the range is not a range of it. + pub(super) fn new(text: &RenderedText, range: &Range, now: Instant) -> Option { + Some(Self { + target: text.index().locate(range)?, + requested_at: now, + report: Arc::default(), + attempts: 0, + }) + } + + pub(super) fn is_expired(&self, now: Instant) -> bool { + now.saturating_duration_since(self.requested_at) > REVEAL_TIMEOUT + || self.attempts >= REVEAL_ATTEMPTS + } + + /// Whether the reveal is of a whole block rather than a line. + pub(super) fn is_block(&self) -> bool { + matches!(self.target, RevealTarget::Block { .. }) + } + + /// The index of the top-level block of `document` the reveal starts in. + pub(super) fn block_ix(&self, document: &ParsedDocument) -> Option { + match self.target { + RevealTarget::Line { key, .. } => document.blocks.iter().rposition(|block| { + block + .span() + .is_some_and(|span| span.start <= key.block_start()) + }), + RevealTarget::Block { ix } => (ix < document.blocks.len()).then_some(ix), + } + } + + /// Whether the line was laid out in the previous frame. + pub(super) fn was_laid_out(&self) -> bool { + self.report.lock().is_ok_and(|report| report.is_some()) + } + + /// Starts a frame: forgets the previous report and hands the line to + /// rendering. A block has no line. + pub(super) fn request(&self) -> Option { + if let Ok(mut report) = self.report.lock() { + *report = None; + } + let RevealTarget::Line { key, offset } = self.target else { + return None; + }; + Some(RevealRequest { + key, + offset, + report: self.report.clone(), + }) + } + + /// Ends a frame with what the line reported, counting a frame in which + /// it was laid out but hidden as an attempt. + pub(super) fn progress(&mut self) -> RevealProgress { + let report = self.report.lock().ok().and_then(|report| *report); + match report { + Some(report) if report.visible => RevealProgress::Shown, + Some(report) => { + self.attempts += 1; + RevealProgress::Hidden(report.line) + } + None => RevealProgress::NotLaidOut, + } + } + + /// The reveal in the document `remap` maps the old one to, as long as + /// the text it starts at is unchanged. + pub(super) fn remap(mut self, remap: &LeafRemap) -> Option { + let RevealTarget::Line { key, offset } = self.target else { + return None; + }; + let (key, unchanged) = remap.leaf(key)?; + (offset < unchanged).then_some(())?; + self.target = RevealTarget::Line { key, offset }; + Some(self) + } +} + +/// The start of a pending reveal, as rendering hands it to the `Inline` +/// that lays that text out. +#[derive(Clone, Debug)] +pub(crate) struct RevealRequest { + key: TextLeafKey, + offset: usize, + report: Arc>>, +} + +impl RevealRequest { + /// The start of the reveal, when it is in `key`'s text between `start` + /// and `end`, rebased to `start`. + pub(crate) fn at( + &self, + key: Option, + start: usize, + end: usize, + ) -> Option { + if key != Some(self.key) { + return None; + } + RevealAt { + offset: self.offset, + report: self.report.clone(), + } + .rebase(start, end) + } +} + +/// The start of a pending reveal, in the byte space of one run of text. +#[derive(Clone, Debug)] +pub(crate) struct RevealAt { + offset: usize, + report: Arc>>, +} + +impl RevealAt { + pub(crate) fn offset(&self) -> usize { + self.offset + } + + /// The reveal in the text between `start` and `end`, rebased to `start`, + /// or `None` when it starts outside it. + pub(crate) fn rebase(&self, start: usize, end: usize) -> Option { + (start..end).contains(&self.offset).then(|| Self { + offset: self.offset - start, + report: self.report.clone(), + }) + } + + /// The reveal moved into the text between `start` and `end` and rebased + /// to `start`: one before it moves to its first character, one after it + /// to its end. + pub(crate) fn clamp(&self, start: usize, end: usize) -> Self { + Self { + offset: self.offset.clamp(start, end) - start, + report: self.report.clone(), + } + } + + /// Report where the line the reveal starts on was laid out, in window + /// coordinates, and whether it was inside the visible area. + pub(crate) fn report(&self, line: Bounds, visible: bool) { + if let Ok(mut report) = self.report.lock() { + *report = Some(RevealReport { line, visible }); + } + } +} diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index 0efab77104..3bb6d1a4e3 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -27,7 +27,9 @@ use crate::{ document::ParsedDocument, format, node::{self, NodeContext}, - range_highlight::{RangeHighlightFrame, RenderedIndex}, + range_highlight::{ + LeafRemap, PendingReveal, RangeHighlightFrame, RenderedIndex, RevealRequest, + }, selection_adapter::TextViewSelectionAdapter, stream_fade::{StreamFadeTracker, TextViewMotion}, }, @@ -144,6 +146,7 @@ pub struct TextViewState { /// The rendered text of `parsed_content`, built when first read. rendered_index: Arc>, range_highlights: Option>, + pub(super) pending_reveal: Option, pub(super) selection_revision: usize, compatible_layout_update: bool, layout_text_style: Option<(gpui::TextStyle, Pixels)>, @@ -261,6 +264,7 @@ impl TextViewState { full_update_revision: 0, rendered_index: Arc::default(), range_highlights: None, + pending_reveal: None, selection_revision: 0, compatible_layout_update: false, layout_text_style: None, @@ -596,15 +600,59 @@ impl TextViewState { highlights: impl IntoIterator, cx: &mut Context, ) -> Result<(), RangeHighlightError> { - let text = self.rendered_text(); if self.format != TextViewFormat::Markdown { return Err(RangeHighlightError::Unsupported); } + let text = self.rendered_text(); self.range_highlights = RangeHighlightFrame::new(&text, highlights)?.map(Arc::new); cx.notify(); Ok(()) } + /// Scroll the line `range` starts on into view, `range` indexing the + /// current rendered text, as with [`Self::set_range_highlights`]. + /// + /// A [`Self::scrollable`] view scrolls itself. Otherwise the nearest + /// enclosing `gpui::list` scrolls, as long as the row holding the view is + /// laid out, so scroll to that row first when it may be off screen. Any + /// other scroll container scrolls through + /// [`TextView::on_reveal`](crate::text::TextView::on_reveal). An empty + /// range reveals the line of its position; a range in text that belongs + /// to no block's text, such as a custom block's, scrolls its whole block + /// into a scrollable view. + /// + /// Only the latest reveal is carried out. It follows the content as + /// [range highlights](Self::set_range_highlights) do, and it is dropped + /// when its text changes, when the view clamps its lines, or when it + /// cannot be shown within a second, so it never scrolls long after it + /// was asked for. A whole block taller than the view, scrolled to from + /// below, shows its end. + /// + /// Revealing is best effort: `Ok(())` means the range is valid for the + /// current text and the request was taken, not that the view has + /// scrolled. A dropped request is not reported. + pub fn reveal_range( + &mut self, + range: Range, + cx: &mut Context, + ) -> Result<(), RangeHighlightError> { + if self.format != TextViewFormat::Markdown { + return Err(RangeHighlightError::Unsupported); + } + let text = self.rendered_text(); + if text.is_empty() && range == (0..0) { + // Nothing to reveal in an empty view. + self.pending_reveal = None; + return Ok(()); + } + let now = cx.background_executor().now(); + self.pending_reveal = Some( + PendingReveal::new(&text, &range, now).ok_or(RangeHighlightError::InvalidRange(0))?, + ); + cx.notify(); + Ok(()) + } + /// Remove all range highlights. pub fn clear_range_highlights(&mut self, cx: &mut Context) { if self.range_highlights.take().is_some() { @@ -623,10 +671,17 @@ impl TextViewState { 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) + if self.range_highlights.is_some() || self.pending_reveal.is_some() { + let remap = LeafRemap::new(&self.parsed_content.document, new, tail_only); + self.range_highlights = self + .range_highlights + .take() + .and_then(|highlights| highlights.remap(&remap)) .map(Arc::new); + self.pending_reveal = self + .pending_reveal + .take() + .and_then(|reveal| reveal.remap(&remap)); } } @@ -851,6 +906,50 @@ pub(crate) enum TextViewMultiClickKind { Line, } +impl TextViewState { + /// Starts a frame of the pending reveal: the line to hand to rendering, + /// and the block a scrollable view has to scroll to first, because it is + /// off screen or the reveal is of a whole block. + fn reveal_frame( + &mut self, + now: Instant, + window: &mut Window, + ) -> (Option, Option) { + if self.max_lines.is_some() + || self + .pending_reveal + .as_ref() + .is_some_and(|reveal| reveal.is_expired(now)) + { + self.pending_reveal = None; + } + let Some(pending) = &self.pending_reveal else { + return (None, None); + }; + + let block_ix = pending.block_ix(&self.parsed_content.document); + let block_off_screen = |ix: usize| { + let viewport = self.list_state.viewport_bounds(); + self.list_state.bounds_for_item(ix).is_none_or(|item| { + item.bottom() <= viewport.top() || item.top() >= viewport.bottom() + }) + }; + let reveal_block = block_ix.filter(|ix| { + self.scrollable + && (pending.is_block() || !pending.was_laid_out()) + && block_off_screen(*ix) + }); + let request = pending.request(); + if pending.is_block() { + // A block has no line to wait for. + self.pending_reveal = None; + } else { + window.request_animation_frame(); + } + (request, reveal_block) + } +} + impl Render for TextViewState { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let typography = (window.text_style(), window.rem_size()); @@ -878,6 +977,7 @@ impl Render for TextViewState { }); })); } + let (reveal, reveal_block) = self.reveal_frame(cx.background_executor().now(), window); // Built every frame, so everything in it is shared, not copied. let node_cx = NodeContext { offset: self.parsed_content.node_cx.offset, @@ -890,9 +990,10 @@ impl Render for TextViewState { markdown_extensions: self.markdown_extensions.clone(), stream_fade, range_highlights: self.range_highlights.clone(), + reveal, }; - v_flex() + let content = v_flex() .w_full() // Clamped content must keep its natural height: stretching it to // the capped box would hide the overflow the clamp has to measure. @@ -946,7 +1047,13 @@ impl Render for TextViewState { { TextSelection::clear(window, cx); } - }) + }); + // After `render_root`, which resets the list when the block count + // changed, and with it the scroll position. + if let Some(block_ix) = reveal_block { + self.list_state.scroll_to_reveal_item(block_ix); + } + content } } @@ -2308,8 +2415,8 @@ mod tests { assert_eq!(painted(&state, TextLeafKey::block(0), cx), [0..5]); } - struct Root { - state: Entity, + pub(super) struct Root { + pub(super) state: Entity, } impl Render for Root { @@ -2359,4 +2466,530 @@ mod tests { assert!(!has_highlights(&state, cx)); } } + + mod reveal_range { + use gpui::{ + Entity, InteractiveElement as _, ListAlignment, ListState, ScrollHandle, + StatefulInteractiveElement as _, TestAppContext, VisualTestContext, div, list, + }; + + use super::super::*; + use crate::text::TextView; + + /// Where the view sits in a 200 × 100 window. + #[derive(Clone)] + enum Container { + /// A scrollable view. + Scrollable, + /// A fit-content view, second row of an application list. + List(ListState), + /// A fit-content view in a scrolling `div`, which follows reveals + /// through `on_reveal`. + Div(ScrollHandle), + /// A fit-content view clamped to two lines, second row of a list. + Clamped(ListState), + /// A fit-content view in nothing that scrolls. + Fixed, + } + + struct Root { + state: Entity, + container: Container, + } + + impl Render for Root { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let state = self.state.clone(); + let frame = div().w(px(200.)).h(px(100.)); + let row = |list_state: &ListState, clamp: bool| { + let state = state.clone(); + list(list_state.clone(), move |ix, _, _| match ix { + 0 => div().h(px(40.)).into_any_element(), + _ => TextView::new(&state) + .when(clamp, |view| view.max_lines(2)) + .into_any_element(), + }) + .size_full() + }; + match &self.container { + Container::Scrollable => frame + .child(TextView::new(&state).scrollable(true)) + .into_any_element(), + Container::List(list_state) => { + frame.child(row(list_state, false)).into_any_element() + } + Container::Clamped(list_state) => { + frame.child(row(list_state, true)).into_any_element() + } + Container::Div(handle) => { + let scroll = handle.clone(); + frame + .child( + div() + .id("scroll") + .size_full() + .overflow_y_scroll() + .track_scroll(handle) + .child(TextView::new(&state).on_reveal(move |line, _, _| { + let viewport = scroll.bounds(); + let mut offset = scroll.offset(); + if line.bottom() > viewport.bottom() { + offset.y -= line.bottom() - viewport.bottom(); + } else if line.top() < viewport.top() { + offset.y += viewport.top() - line.top(); + } + scroll.set_offset(offset); + })), + ) + .into_any_element() + } + Container::Fixed => frame.child(TextView::new(&state)).into_any_element(), + } + } + } + + fn window<'a>( + markdown: &str, + container: Container, + cx: &'a mut TestAppContext, + ) -> (Entity, &'a mut VisualTestContext) { + cx.update(crate::init); + let (root, cx) = cx.add_window_view(|_, cx| Root { + state: cx.new(|cx| TextViewState::markdown(markdown, cx)), + container, + }); + cx.run_until_parked(); + draw(cx); + let state = root.read_with(cx, |root, _| root.state.clone()); + (state, cx) + } + + fn draw(cx: &mut VisualTestContext) { + cx.update(|window, cx| window.draw(cx).clear(cx)); + } + + /// Asks to reveal the first occurrence of `needle`, then runs `then` + /// before anything is drawn. + fn request( + state: &Entity, + needle: &str, + cx: &mut VisualTestContext, + then: impl FnOnce(&mut TextViewState, &mut Context), + ) { + state.update(cx, |state, cx| { + let text = state.rendered_text(); + let start = text.as_str().find(needle).unwrap(); + state.reveal_range(start..start + needle.len(), cx).unwrap(); + then(state, cx); + }); + } + + /// Reveals the first occurrence of `needle` and draws a few frames. + fn reveal(state: &Entity, needle: &str, cx: &mut VisualTestContext) { + request(state, needle, cx, |_, _| {}); + for _ in 0..3 { + draw(cx); + } + } + + fn is_pending(state: &Entity, cx: &mut VisualTestContext) -> bool { + state.read_with(cx, |state, _| state.pending_reveal.is_some()) + } + + fn scroll_top( + state: &Entity, + cx: &mut VisualTestContext, + ) -> gpui::ListOffset { + state.read_with(cx, |state, _| state.list_state.logical_scroll_top()) + } + + fn paragraphs(count: usize) -> String { + (0..count) + .map(|ix| format!("paragraph {ix}")) + .collect::>() + .join("\n\n") + } + + fn words(count: usize) -> String { + (0..count) + .map(|ix| format!("w{ix}")) + .collect::>() + .join(" ") + } + + #[gpui::test] + fn a_scrollable_view_scrolls_to_an_offscreen_block(cx: &mut TestAppContext) { + let (state, cx) = window(¶graphs(200), Container::Scrollable, cx); + reveal(&state, "paragraph 150", cx); + assert!(!is_pending(&state, cx)); + let top = scroll_top(&state, cx); + assert!((140..=150).contains(&top.item_ix), "{top:?}"); + + reveal(&state, "paragraph 3", cx); + assert!(!is_pending(&state, cx)); + assert!(scroll_top(&state, cx).item_ix <= 3); + } + + #[gpui::test] + fn a_scrollable_view_scrolls_to_a_line_inside_a_long_paragraph(cx: &mut TestAppContext) { + let (state, cx) = window(&words(400), Container::Scrollable, cx); + reveal(&state, "w390", cx); + assert!(!is_pending(&state, cx)); + let top = scroll_top(&state, cx); + assert_eq!(top.item_ix, 0); + assert!(top.offset_in_item > px(100.), "{top:?}"); + } + + #[gpui::test] + fn revealing_a_visible_line_does_not_scroll(cx: &mut TestAppContext) { + let (state, cx) = window(&words(400), Container::Scrollable, cx); + reveal(&state, "w200", cx); + let top = scroll_top(&state, cx); + assert!(top.offset_in_item > px(0.), "{top:?}"); + for word in ["w199", "w198", "w197", "w196"] { + reveal(&state, word, cx); + assert!(!is_pending(&state, cx)); + let now = scroll_top(&state, cx); + assert_eq!( + (now.item_ix, now.offset_in_item), + (top.item_ix, top.offset_in_item), + "{word}" + ); + } + } + + #[gpui::test] + fn an_enclosing_list_scrolls_to_a_line_of_a_fit_content_view(cx: &mut TestAppContext) { + let outer = ListState::new(2, ListAlignment::Top, px(1000.)); + let (state, cx) = window(&words(400), Container::List(outer.clone()), cx); + reveal(&state, "w390", cx); + assert!(!is_pending(&state, cx)); + let top = outer.logical_scroll_top(); + assert_eq!(top.item_ix, 1, "{top:?}"); + assert!(top.offset_in_item > px(100.), "{top:?}"); + } + + #[gpui::test] + fn on_reveal_scrolls_a_container_that_ignores_scroll_requests(cx: &mut TestAppContext) { + let handle = ScrollHandle::new(); + let (state, cx) = window(&words(400), Container::Div(handle.clone()), cx); + reveal(&state, "w390", cx); + assert!(!is_pending(&state, cx)); + assert!(handle.offset().y < px(-100.), "{:?}", handle.offset()); + } + + #[gpui::test] + fn a_reveal_that_cannot_be_shown_gives_up(cx: &mut TestAppContext) { + let (state, cx) = window(&words(4000), Container::Fixed, cx); + reveal(&state, "w3990", cx); + assert!(is_pending(&state, cx)); + for _ in 0..10 { + draw(cx); + } + assert!(!is_pending(&state, cx)); + } + + #[gpui::test] + fn a_reveal_not_carried_out_in_time_is_dropped(cx: &mut TestAppContext) { + let (state, cx) = window(¶graphs(200), Container::Scrollable, cx); + request(&state, "paragraph 150", cx, |_, cx| { + cx.background_executor() + .advance_clock(std::time::Duration::from_secs(2)) + }); + draw(cx); + assert!(!is_pending(&state, cx)); + assert_eq!(scroll_top(&state, cx).item_ix, 0); + } + + #[gpui::test] + fn an_empty_range_reveals_its_line(cx: &mut TestAppContext) { + let (state, cx) = window(&words(400), Container::Scrollable, cx); + reveal(&state, "w390", cx); + let top = scroll_top(&state, cx); + // A position on a visible line leaves the view where it is. + state.update(cx, |state, cx| { + let text = state.rendered_text(); + let start = text.as_str().find("w391").unwrap(); + state.reveal_range(start..start, cx).unwrap(); + }); + for _ in 0..3 { + draw(cx); + } + assert!(!is_pending(&state, cx)); + let now = scroll_top(&state, cx); + assert_eq!( + (now.item_ix, now.offset_in_item), + (top.item_ix, top.offset_in_item) + ); + } + + #[gpui::test] + fn a_position_after_the_last_character_reveals_its_line(cx: &mut TestAppContext) { + let (state, cx) = window(&words(400), Container::Scrollable, cx); + reveal(&state, "w399", cx); + let top = scroll_top(&state, cx); + assert!(top.offset_in_item > px(1000.), "{top:?}"); + // The end of the text, on the visible last line. + state.update(cx, |state, cx| { + let text = state.rendered_text(); + let end = text.as_str().find("w399").unwrap() + "w399".len(); + state.reveal_range(end..end, cx).unwrap(); + }); + for _ in 0..3 { + draw(cx); + } + assert!(!is_pending(&state, cx)); + let now = scroll_top(&state, cx); + assert_eq!( + (now.item_ix, now.offset_in_item), + (top.item_ix, top.offset_in_item) + ); + } + + /// Reveals `range` of the current text and draws a few frames. + fn reveal_at( + state: &Entity, + range: impl FnOnce(&str) -> std::ops::Range, + cx: &mut VisualTestContext, + ) { + state.update(cx, |state, cx| { + let text = state.rendered_text(); + let range = range(text.as_str()); + state.reveal_range(range, cx).unwrap(); + }); + for _ in 0..3 { + draw(cx); + } + } + + fn assert_unmoved( + state: &Entity, + top: gpui::ListOffset, + cx: &mut VisualTestContext, + ) { + assert!(!is_pending(state, cx)); + let now = scroll_top(state, cx); + assert_eq!( + (now.item_ix, now.offset_in_item), + (top.item_ix, top.offset_in_item) + ); + } + + #[gpui::test] + fn the_end_of_the_text_and_its_separators_reveal_the_last_line(cx: &mut TestAppContext) { + let (state, cx) = window(&words(400), Container::Scrollable, cx); + reveal(&state, "w399", cx); + let top = scroll_top(&state, cx); + assert!(top.offset_in_item > px(1000.), "{top:?}"); + // The end of the text, after the separator that ends the block. + reveal_at(&state, |text| text.len()..text.len(), cx); + assert_unmoved(&state, top, cx); + // Only that separator. + reveal_at(&state, |text| text.len() - 1..text.len(), cx); + assert_unmoved(&state, top, cx); + + let code = (0..200) + .map(|ix| format!("line {ix}")) + .collect::>() + .join("\n"); + state.update(cx, |state, cx| { + state.set_text(&format!("```\n{code}\n```"), cx) + }); + cx.run_until_parked(); + reveal(&state, "line 199", cx); + let top = scroll_top(&state, cx); + assert!(top.offset_in_item > px(1000.), "{top:?}"); + reveal_at(&state, |text| text.len()..text.len(), cx); + assert_unmoved(&state, top, cx); + } + + #[gpui::test] + fn the_end_of_a_block_above_reveals_its_last_line(cx: &mut TestAppContext) { + let second = (0..400) + .map(|ix| format!("v{ix}")) + .collect::>() + .join(" "); + let markdown = format!("{}\n\n{second}", words(400)); + let (state, cx) = window(&markdown, Container::Scrollable, cx); + reveal(&state, "v399", cx); + assert_eq!(scroll_top(&state, cx).item_ix, 1); + // The end of the first paragraph, on the separator after it. + reveal_at( + &state, + |text| { + let end = text.find("w399").unwrap() + "w399".len(); + end..end + }, + cx, + ); + assert!(!is_pending(&state, cx)); + let top = scroll_top(&state, cx); + assert_eq!(top.item_ix, 0, "{top:?}"); + assert!(top.offset_in_item > px(1000.), "{top:?}"); + } + + #[gpui::test] + fn an_empty_view_has_nothing_to_reveal(cx: &mut TestAppContext) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("", cx))); + cx.run_until_parked(); + state.update(cx, |state, cx| { + assert_eq!(state.reveal_range(0..0, cx), Ok(())); + assert!(state.pending_reveal.is_none()); + }); + } + + #[gpui::test] + fn revealing_a_visible_block_does_not_scroll(cx: &mut TestAppContext) { + let markdown = format!("{}\n\n
html
\n\n{}", words(400), words(400)); + let (state, cx) = window(&markdown, Container::Scrollable, cx); + reveal(&state, "html", cx); + // Still in view a little further down. + state.update(cx, |state, _| state.list_state.scroll_by(px(30.))); + draw(cx); + let top = scroll_top(&state, cx); + reveal(&state, "html", cx); + assert_unmoved(&state, top, cx); + } + + #[gpui::test] + fn a_line_of_an_inline_flow_counts_as_shown_once_scrolled_to(cx: &mut TestAppContext) { + // Inline code every tenth word, so the rows are taller than the + // body line and land between pixels. + let markdown = (0..400) + .map(|ix| { + if ix % 10 == 0 { + format!("`c{ix}`") + } else { + format!("w{ix}") + } + }) + .collect::>() + .join(" "); + let (state, cx) = window(&markdown, Container::Scrollable, cx); + reveal(&state, "c390", cx); + assert!(!is_pending(&state, cx)); + assert!(scroll_top(&state, cx).offset_in_item > px(1000.)); + } + + #[gpui::test] + fn a_range_starting_on_a_line_break_reveals_the_next_line(cx: &mut TestAppContext) { + let code = (0..200) + .map(|ix| format!("line {ix}")) + .collect::>() + .join("\n"); + let (state, cx) = window(&format!("```\n{code}\n```"), Container::Scrollable, cx); + reveal(&state, "\nline 190", cx); + assert!(!is_pending(&state, cx)); + let top = scroll_top(&state, cx); + assert!(top.offset_in_item > px(1000.), "{top:?}"); + } + + #[gpui::test] + fn a_range_starting_on_a_line_break_in_an_inline_flow_reveals_the_next_line( + cx: &mut TestAppContext, + ) { + // Inline code lays the paragraph out as an inline flow. + let lines = (0..200) + .map(|ix| format!("line {ix} `code`")) + .collect::>() + .join("\\\n"); + let (state, cx) = window(&lines, Container::Scrollable, cx); + reveal(&state, "\nline 190", cx); + assert!(!is_pending(&state, cx)); + let top = scroll_top(&state, cx); + assert!(top.offset_in_item > px(1000.), "{top:?}"); + } + + #[gpui::test] + fn a_reveal_is_dropped_when_its_text_before_it_changes(cx: &mut TestAppContext) { + let (state, cx) = window(¶graphs(200), Container::Scrollable, cx); + let target = "first words then the target"; + let markdown = format!("{target}\n\n{}", paragraphs(200)); + state.update(cx, |state, cx| state.set_text(&markdown, cx)); + cx.run_until_parked(); + + // Appending to its paragraph keeps it. + request(&state, "target", cx, |state, cx| { + state.set_text(&markdown.replacen("target", "target and more", 1), cx); + assert!(state.pending_reveal.is_some()); + }); + // An edit before it in its paragraph drops it. + request(&state, "target", cx, |state, cx| { + state.set_text(&markdown.replacen("words", "WORDS", 1), cx); + assert!(state.pending_reveal.is_none()); + }); + } + + #[gpui::test] + fn a_clamped_view_does_not_reveal(cx: &mut TestAppContext) { + let outer = ListState::new(2, ListAlignment::Top, px(1000.)); + let (state, cx) = window(&words(400), Container::Clamped(outer.clone()), cx); + reveal(&state, "w390", cx); + assert!(!is_pending(&state, cx)); + assert_eq!(outer.logical_scroll_top().item_ix, 0); + } + + #[gpui::test] + fn text_outside_every_block_reveals_its_block(cx: &mut TestAppContext) { + let markdown = format!("{}\n\n
html text
", paragraphs(100)); + let (state, cx) = window(&markdown, Container::Scrollable, cx); + reveal(&state, "html text", cx); + assert!(!is_pending(&state, cx)); + assert!( + scroll_top(&state, cx).item_ix >= 90, + "{:?}", + scroll_top(&state, cx) + ); + } + + #[gpui::test] + fn a_reveal_follows_its_text_past_an_edit_before_it(cx: &mut TestAppContext) { + let (state, cx) = window(¶graphs(200), Container::Scrollable, cx); + // An inserted paragraph moves the target down one block. + request(&state, "paragraph 150", cx, |state, cx| { + state.set_text(&format!("inserted\n\n{}", paragraphs(200)), cx); + assert!(state.pending_reveal.is_some()); + }); + for _ in 0..3 { + draw(cx); + } + assert!(!is_pending(&state, cx)); + let top = scroll_top(&state, cx); + assert!((141..=151).contains(&top.item_ix), "{top:?}"); + + // A reveal whose text changed is dropped. + request(&state, "paragraph 150", cx, |state, cx| { + state.set_text(¶graphs(200).replace("paragraph 150", "changed"), cx); + assert!(state.pending_reveal.is_none()); + }); + } + + #[gpui::test] + fn malformed_ranges_and_html_views_are_rejected(cx: &mut TestAppContext) { + cx.update(crate::init); + let state = cx.update(|cx| cx.new(|cx| TextViewState::markdown("first\n\nsecond", cx))); + cx.run_until_parked(); + state.update(cx, |state, cx| { + assert_eq!( + state.reveal_range(std::ops::Range { start: 5, end: 3 }, cx), + Err(RangeHighlightError::InvalidRange(0)) + ); + assert_eq!( + state.reveal_range(0..100, cx), + Err(RangeHighlightError::InvalidRange(0)) + ); + state.reveal_range(0..5, cx).unwrap(); + }); + + let html = cx.update(|cx| cx.new(|cx| TextViewState::html("

one

", cx))); + cx.run_until_parked(); + html.update(cx, |html, cx| { + assert_eq!( + html.reveal_range(0..3, cx), + Err(RangeHighlightError::Unsupported) + ); + }); + } + } } diff --git a/crates/base/src/text/text_view.rs b/crates/base/src/text/text_view.rs index 692102e86d..4e682fd63d 100644 --- a/crates/base/src/text/text_view.rs +++ b/crates/base/src/text/text_view.rs @@ -1,4 +1,4 @@ -use std::{ops::Range, sync::Arc}; +use std::{ops::Range, rc::Rc, sync::Arc}; use gpui::prelude::FluentBuilder as _; use gpui::{ @@ -12,6 +12,7 @@ use crate::StyledExt; use crate::text::TextViewFormat; use crate::text::markdown_ext::{MarkdownExtensions, MarkdownNode, MarkdownPlugin}; use crate::text::node::{CodeBlock, TableData}; +use crate::text::range_highlight::{PendingReveal, RevealProgress}; use crate::text::state::{LineSpan, SelectionFormat, TextViewState}; use crate::text::stream_fade::TextViewMotion; use crate::{GlobalState, TextSelection, text::TextViewStyle}; @@ -77,6 +78,10 @@ pub(crate) type TableActionsFn = pub(crate) type LinkClickHandlerFn = dyn Fn(&SharedString, &ClickEvent, &mut Window, &mut App) + Send + Sync; +/// Kept by the element only, so unlike the handlers the state carries, it +/// may hold a `ScrollHandle`. +pub(crate) type RevealHandlerFn = dyn Fn(Bounds, &mut Window, &mut App); + pub(crate) fn handle_link_click( handler: &Option>, url: SharedString, @@ -129,6 +134,7 @@ pub struct TextView { code_block_highlighter: Option>, table_actions: Option>, link_click_handler: Option>, + reveal_handler: Option>, markdown_extensions: Arc, motion: Option, } @@ -174,6 +180,7 @@ impl TextView { code_block_highlighter: None, table_actions: None, link_click_handler: None, + reveal_handler: None, markdown_extensions: Arc::default(), motion: None, } @@ -196,6 +203,7 @@ impl TextView { code_block_highlighter: None, table_actions: None, link_click_handler: None, + reveal_handler: None, markdown_extensions: Arc::default(), motion: None, } @@ -218,6 +226,7 @@ impl TextView { code_block_highlighter: None, table_actions: None, link_click_handler: None, + reveal_handler: None, markdown_extensions: Arc::default(), motion: None, } @@ -338,6 +347,22 @@ impl TextView { self } + /// Scroll a container that does not follow scroll requests to the line + /// of [`TextViewState::reveal_range`]. + /// + /// A `gpui::list` scrolls to that line by itself; a `div` with + /// `overflow_y_scroll`, for one, does not. After a frame in which the + /// line was laid out but not visible, the handler receives its bounds in + /// window coordinates, to scroll the container, e.g. through its + /// `ScrollHandle`, until the line is visible. + pub fn on_reveal(mut self, handler: F) -> Self + where + F: Fn(Bounds, &mut Window, &mut App) + 'static, + { + self.reveal_handler = Some(Rc::new(handler)); + self + } + /// Replace the Markdown extension registry. pub fn markdown_extensions(mut self, extensions: MarkdownExtensions) -> Self { self.markdown_extensions = Arc::new(extensions); @@ -748,6 +773,25 @@ impl Element for TextView { } GlobalState::global_mut(cx).text_view_state_stack.pop(); + // Every list has scrolled by now, so the line of a reveal is where + // it ends up this frame. + if state.read(cx).pending_reveal.is_some() { + let progress = state.update(cx, |state, _| { + state.pending_reveal.as_mut().map(PendingReveal::progress) + }); + match progress { + Some(RevealProgress::Shown) => { + state.update(cx, |state, _| state.pending_reveal = None); + } + Some(RevealProgress::Hidden(line)) => { + if let Some(handler) = &self.reveal_handler { + handler(line, window, cx); + } + } + Some(RevealProgress::NotLaidOut) | None => {} + } + } + if self.selectable { let (adapter, scroll_offset, content_bounds, self_scroll, handle_color) = { let state = state.read(cx); diff --git a/crates/component/src/text/compat.rs b/crates/component/src/text/compat.rs index 0bedaed9ff..c994ebd858 100644 --- a/crates/component/src/text/compat.rs +++ b/crates/component/src/text/compat.rs @@ -153,6 +153,15 @@ impl TextView { self.inner = self.inner.on_link_click(f); self } + /// Scrolls a container that ignores scroll requests to the line of + /// `TextViewState::reveal_range`, with the line's window bounds. + pub fn on_reveal(mut self, f: F) -> Self + where + F: Fn(Bounds, &mut Window, &mut App) + 'static, + { + self.inner = self.inner.on_reveal(f); + self + } /// Sets which Markdown extensions the parser accepts. pub fn markdown_extensions(mut self, value: MarkdownExtensions) -> Self { self.inner = self.inner.markdown_extensions(value); diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index 7543d00b63..4e57dc8b3a 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -10,7 +10,7 @@ use std::{ use gpui_component_story::Open; use gpui_kit::assets::Assets; use gpui_kit::component::{ - ActiveTheme as _, Icon, IconName, Sizable as _, + ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, avatar::Avatar, button::{Button, ButtonVariants as _}, clipboard::Clipboard, @@ -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, RangeHighlightError, RenderedText, SelectionFormat, TextView, + TextViewState, TextViewStyle, markdown_ast, }, v_flex, }; @@ -1184,9 +1184,11 @@ pub struct Example { /// source. selection_format: SelectionFormat, find_state: Entity, - /// The preview text the find query was last highlighted in. + /// The preview text the find query was last searched in. searched: Option, - match_count: usize, + matches: Vec>, + /// The index of the current match in `matches`. + current_match: usize, _subscriptions: Vec, } @@ -1227,11 +1229,14 @@ impl Example { let _subscriptions = vec![ cx.subscribe(&input_state, |_, _, _: &InputEvent, cx| cx.notify()), - cx.subscribe(&find_state, |this, _, event: &InputEvent, cx| { - if matches!(event, InputEvent::Change) { + cx.subscribe(&find_state, |this, _, event: &InputEvent, cx| match event { + InputEvent::Change => { this.searched = None; + this.current_match = 0; this.highlight_matches(cx); } + InputEvent::PressEnter { shift, .. } => this.go_to_match(!shift, cx), + _ => {} }), // Search again whenever the preview's content changes. cx.observe(&text_view, |this, _, cx| this.highlight_matches(cx)), @@ -1246,42 +1251,84 @@ impl Example { selection_format: SelectionFormat::Plain, find_state, searched: None, - match_count: 0, + matches: Vec::new(), + current_match: 0, _subscriptions, } } - /// Highlight every occurrence of the find query in the preview, unless - /// the preview text it was last highlighted in is still current. + /// Search the preview for the find query, unless the preview text it + /// was last searched in is still current, and highlight the matches. 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(); + self.matches = if query.is_empty() { + Vec::new() + } else { + text.as_str() + .match_indices(query.as_str()) + .map(|(start, found)| start..start + found.len()) + .collect() + }; + self.current_match = self.current_match.min(self.matches.len().saturating_sub(1)); + let query_changed = self.searched.is_none(); + self.searched = Some(text); + // Typing a query scrolls to its first match; content changing + // under an unchanged query leaves the view where it is. + self.paint_matches(query_changed, cx); + } + + /// Step to the next match, or the previous one, and scroll to it. + fn go_to_match(&mut self, forward: bool, cx: &mut Context) { + let count = self.matches.len(); + if count == 0 { + return; + } + self.current_match = if forward { + (self.current_match + 1) % count + } else { + (self.current_match + count - 1) % count + }; + self.paint_matches(true, cx); + } + + /// Highlight the matches, the current one stronger, and scroll to it + /// when `reveal` is set. + fn paint_matches(&mut self, reveal: bool, cx: &mut Context) { + let Some(searched) = self.searched.clone() else { + return; + }; let color = cx.theme().warning.opacity(0.3); - let searched = self.searched.as_ref(); + let current_color = cx.theme().warning; + let highlights = self.matches.iter().enumerate().map(|(ix, range)| { + RangeHighlight::new( + range.clone(), + if ix == self.current_match { + current_color + } else { + color + }, + ) + }); + let current = self.matches.get(self.current_match).cloned(); let result = self.text_view.update(cx, |state, cx| { - let text = state.rendered_text(); - if searched == Some(&text) { - return None; + // The matches are ranges of the text they were found in. + if state.rendered_text() != searched { + return Ok(()); } - 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))) + state.set_range_highlights(highlights, cx)?; + if reveal && let Some(current) = current { + state.reveal_range(current, cx)?; + } + Ok::<_, RangeHighlightError>(()) }); - 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(); } @@ -1523,12 +1570,39 @@ impl Render for Example { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(match self.match_count { - 1 => "1 match".to_string(), - count => format!("{count} matches"), + .child(if self.matches.is_empty() { + "No matches".to_string() + } else { + format!( + "{} of {}", + self.current_match + 1, + self.matches.len() + ) }), ) - }), + }) + .child( + Button::new("previous-match") + .icon(IconName::ChevronUp) + .ghost() + .xsmall() + .disabled(self.matches.is_empty()) + .tooltip("Previous Match") + .on_click(cx.listener(|this, _, _, cx| { + this.go_to_match(false, cx) + })), + ) + .child( + Button::new("next-match") + .icon(IconName::ChevronDown) + .ghost() + .xsmall() + .disabled(self.matches.is_empty()) + .tooltip("Next Match") + .on_click(cx.listener(|this, _, _, cx| { + this.go_to_match(true, cx) + })), + ), ) .right( Button::new("preview-zoom") diff --git a/website/base/text-view.md b/website/base/text-view.md index c6bbe5a904..da2563079d 100644 --- a/website/base/text-view.md +++ b/website/base/text-view.md @@ -248,8 +248,11 @@ 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. +The ranges are painted, not shaped, so they never change layout. +`reveal_range` scrolls the line a range starts on into view, through the +view's own list, an enclosing `gpui::list`, or `TextView::on_reveal` for any +other container; see [Highlight ranges](../component/text-view.md#highlight-ranges) +and [Scroll to a range](../component/text-view.md#scroll-to-a-range). 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). diff --git a/website/component/text-view.md b/website/component/text-view.md index cd9bd13a33..2623401fac 100644 --- a/website/component/text-view.md +++ b/website/component/text-view.md @@ -160,6 +160,53 @@ current text. Backgrounds that are part of the text, such as background is painted under it), and highlights do not fade in with streamed text. HTML views do not support range highlights. +### Scroll to a range + +`reveal_range` scrolls to a range of the same text, such as the current +result when the user steps to the next one: + +```rust +state.reveal_range(current_range, cx)?; +``` + +It scrolls the line the range starts on into view, down to a line in the +middle of a long paragraph, and leaves the view where it is when that line, or +a whole block revealed, is already visible. An empty range reveals the line of +its position. A `scrollable` view scrolls itself. A fit-content view scrolls +the nearest enclosing `gpui::list`, as a chat transcript is, as long as the +row that holds the view is laid out: scroll to that row first when it may be +off screen. Any other scroll container, such as a `div` with +`overflow_y_scroll`, scrolls through `on_reveal`, which receives the line's +bounds in window coordinates: + +```rust +let scroll = scroll_handle.clone(); +TextView::new(&state).on_reveal(move |line, _, _| { + let viewport = scroll.bounds(); + let mut offset = scroll.offset(); + if line.bottom() > viewport.bottom() { + offset.y -= line.bottom() - viewport.bottom(); + } else if line.top() < viewport.top() { + offset.y += viewport.top() - line.top(); + } + scroll.set_offset(offset); +}) +``` + +A range that covers no block's text, such as a custom block's, scrolls its +whole block into a scrollable view. Only the latest reveal is carried out. It +follows the content the way highlights do, and it is dropped when its text +changes, when the view clamps its lines with `max_lines`, or when it cannot be +shown within a second, so it never scrolls long after it was asked for. Text +scrolled sideways inside a table stays where it is, a block revealed whole and +taller than the view shows its end when it comes from below, a scrollable +view inside an application list scrolls only itself, and views sharing one +state share one reveal. + +Revealing is best effort. `Ok(())` means the range is valid for the current +text and the request was taken, not that the view has scrolled, and a dropped +request is not reported. + ## 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 a246b61e0f..167498e538 100644 --- a/website/zh-CN/base/text-view.md +++ b/website/zh-CN/base/text-view.md @@ -218,7 +218,7 @@ TextView::new(&document).motion( 不设错位时每次更新整块一起淡入。设了错位时,追加的文字按词拆分(词带上其后的空白),中日韩文字按字拆分;一次追加很长时会压缩错位,保证最后一个词在一个淡入时长内开始。追踪器比较的是渲染后的文字而不是源码字节,因此 `set_text` 传入以当前文本为前缀的更长文本会被视为追加;流式过程中被补齐的 Markdown 标记(`**bo` 变成粗体 `bold`)只让发生变化的字形重新淡入,不会整段闪烁。每次只比较更新触及的块,并且只在还有文字在淡入时才请求下一帧。系统开启减少动态效果时跳过淡入。 -`TextViewState::set_range_highlights` 在 `rendered_text()`(与纯文本复制得到的文字一致)的指定范围后面绘制背景,应用可以借此显示搜索结果或引用位置,无需重新解析或修改文档样式。这些范围只参与绘制、不参与排版,因此不会改变布局;规则详见[高亮文本范围](../component/text-view.md#高亮文本范围)。 +`TextViewState::set_range_highlights` 在 `rendered_text()`(与纯文本复制得到的文字一致)的指定范围后面绘制背景,应用可以借此显示搜索结果或引用位置,无需重新解析或修改文档样式。这些范围只参与绘制、不参与排版,因此不会改变布局。`reveal_range` 通过视图自身的列表、外层 `gpui::list`,或者其他容器上的 `TextView::on_reveal`,把范围起点所在的行滚动到可见区域内;规则详见[高亮文本范围](../component/text-view.md#高亮文本范围)和[滚动到范围](../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 5c40abb9b5..a24a7ab0b2 100644 --- a/website/zh-CN/component/text-view.md +++ b/website/zh-CN/component/text-view.md @@ -97,6 +97,34 @@ fn highlight_matches( 内容变化时,高亮会跟随它所在的块,保留到这个块里文字开始变化的位置为止。流式追加的文字(无论通过 `push_str` 还是 `set_text`)不影响前面的高亮;修改某处时,修改前后的高亮都会保留。表格单元格只按位置区分,因此修改表格内部时,被修改的那一行及其后各行单元格的高亮都会失效。视图会发出通知:观察这个 state,重新在新的 `rendered_text()` 里搜索即可。请在同一次 state 更新中计算范围并调用 `set_range_highlights`,确保范围对应当前文本。属于文字本身的背景(例如 `` 和语法高亮)会覆盖在范围高亮之上(行内代码的背景在高亮之下),高亮也不会随流式文字一起淡入。HTML 视图不支持范围高亮。 +### 滚动到范围 + +`reveal_range` 滚动到同一份文本里的某个范围,比如用户跳到下一个结果时的当前结果: + +```rust +state.reveal_range(current_range, cx)?; +``` + +它把范围起点所在的那一行滚动到可见区域内,长段落中间的某一行也能定位到;这一行(或整块显示的块)已经可见时,视图保持不动。空范围会显示它所在位置的那一行。`scrollable` 视图自己滚动。按内容高度排版的视图会让最近的外层 `gpui::list` 滚动,聊天记录就是这种情况,前提是承载视图的那一行已经完成布局,这一行可能不在屏幕上时,先滚动到这一行。其他滚动容器(例如设置了 `overflow_y_scroll` 的 `div`)通过 `on_reveal` 滚动,回调会收到这一行在窗口坐标中的位置: + +```rust +let scroll = scroll_handle.clone(); +TextView::new(&state).on_reveal(move |line, _, _| { + let viewport = scroll.bounds(); + let mut offset = scroll.offset(); + if line.bottom() > viewport.bottom() { + offset.y -= line.bottom() - viewport.bottom(); + } else if line.top() < viewport.top() { + offset.y += viewport.top() - line.top(); + } + scroll.set_offset(offset); +}) +``` + +不覆盖任何块文字的范围(例如自定义块里的文字)会把整个块滚动到可滚动视图的可见区域内。只执行最后一次请求。它像高亮一样跟随内容变化;如果它所在的文字发生变化、视图用 `max_lines` 限制了行数,或者一秒内无法显示,请求就会被取消,因此不会在很久之后才突然滚动。表格里横向滚出的文字不会被滚动出来;整块显示且比视图更高的块从下方滚入时,显示的是它的末尾;放在应用列表里的可滚动视图只滚动自身;共用同一个 state 的多个视图共用同一个请求。 + +滚动请求只是尽力而为:返回 `Ok(())` 表示范围对当前文本有效、请求已被接受,并不表示视图已经滚动;请求被取消时也不会另行通知。 + ## 触摸选择 在触摸屏上,长按会选中手指下的单词,手指按住不放时选区跟随手指移动。抬起手指后,选区上方会出现包含 `复制` 和 `全选` 的编辑菜单,并在选区两端各显示一个拖动 handle。拖动 handle 会移动对应的一端,另一端保持不动;`全选` 选中被按下的那个视图,其 handle 仍可继续调整结果。