diff --git a/crates/base/README.md b/crates/base/README.md index 853ec92d11..f9eca97e4f 100644 --- a/crates/base/README.md +++ b/crates/base/README.md @@ -82,6 +82,19 @@ fn main() { If the application already calls `gpui_kit::init(cx)`, do not call `gpui_kit::base::init(cx)` again. The higher-level initializer includes base initialization. +## Retained Rich-Text Selection + +Managed `TextViewState` retains completed logical selections and their Copy +payload across style, syntax-highlighting, font, and width changes by default. +Current styles and wrapping still apply; an old pointer rectangle is not used to +reconstruct the selection after reflow. Later outer-layout origin changes also +preserve the selected text. A new selection gesture or explicit clear replaces +or clears the selection normally. + +Replacing the source or Markdown parser configuration still invalidates selection. +The owning view must retain the state and its painted selection participant; this +behavior does not pin rows unmounted by an outer virtual list. + ## Quick Start Foundation controls can be styled and given children like ordinary GPUI elements: diff --git a/crates/base/src/text/selection_adapter.rs b/crates/base/src/text/selection_adapter.rs index 06d925dc6e..2cd38ae936 100644 --- a/crates/base/src/text/selection_adapter.rs +++ b/crates/base/src/text/selection_adapter.rs @@ -19,19 +19,29 @@ struct VirtualBlockSelection { anchor: Option, cursor: Option, coverage: TextSelectionCoverage, + is_selecting: bool, } impl VirtualBlockSelection { - fn update(&mut self, snapshot: Option, entity_id: EntityId) { + fn update(&mut self, snapshot: Option, entity_id: EntityId) -> bool { let Some(snapshot) = snapshot else { + let changed = self.anchor.is_some() || self.cursor.is_some(); *self = Self::default(); - return; + return changed; }; + // Origin and scroll changes only alter the window projection. They must + // not turn a held logical range back into a hit test in reflowed text. + let changed = self.anchor.map(|cached| cached.endpoint) != Some(snapshot.anchor()) + || self.cursor.map(|cached| cached.endpoint) != Some(snapshot.cursor()) + || self.coverage != snapshot.coverage() + || self.is_selecting != snapshot.is_selecting(); self.coverage = snapshot.coverage(); + self.is_selecting = snapshot.is_selecting(); Self::update_endpoint(&mut self.anchor, snapshot.anchor(), entity_id); Self::update_endpoint(&mut self.cursor, snapshot.cursor(), entity_id); + changed } fn update_endpoint( @@ -103,10 +113,12 @@ impl TextViewSelectionAdapter { TextSelectionEvent::SelectionChanged(snapshot) => { let snapshot = *snapshot; let _ = view_for_events.update(cx, |state, cx| { - state.preserve_inline_selection = false; - blocks_for_events + let changed = blocks_for_events .borrow_mut() .update(snapshot, selection_id); + if snapshot.is_none() || changed { + state.preserve_inline_selection = false; + } state.is_selecting = snapshot.is_some_and(|snapshot| snapshot.is_selecting()); cx.notify(); diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index 5342a63cd4..81d8a9aa6d 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -120,7 +120,7 @@ pub struct TextViewState { pub(super) markdown_extensions: Arc, pub(super) is_selecting: bool, - /// Logical ranges retained across an explicitly requested resource reflow. + /// Logical ranges retained across compatible resource or presentation reflow. pub(super) preserve_inline_selection: bool, multi_click_selection: Option, selected_text_override: Option, @@ -489,10 +489,14 @@ impl TextViewState { /// when the task can outlive this view. This does not reparse the document. /// Existing logical selection is retained until the next selection gesture. pub fn invalidate_inline_layout(&mut self, cx: &mut Context) { + self.preserve_selection_for_reflow(); + cx.notify(); + } + + pub(super) fn preserve_selection_for_reflow(&mut self) { self.preserve_inline_selection = true; self.compatible_layout_update = true; self.invalidate_measured_heights(); - cx.notify(); } fn increment_update(&mut self, text: &str, append: bool, cx: &mut Context) { @@ -1047,9 +1051,7 @@ impl Render for TextViewState { // ListState invalidates its cached rows for width changes, but does // not know that an inherited font or rem change affects offscreen // inline metrics. Retain logical selections just as for resources. - self.preserve_inline_selection = true; - self.compatible_layout_update = true; - self.invalidate_measured_heights(); + self.preserve_selection_for_reflow(); } self.layout_text_style = Some(typography); let state = cx.entity(); @@ -1111,14 +1113,24 @@ impl Render for TextViewState { has_selection_snapshot, is_selecting, compatible_layout_update, + preserve_width_selection, ) = { let state = state.read(cx); + let has_selection_snapshot = state.selection_adapter.has_selection_snapshot(cx); + let preserve_width_selection = !state.is_selecting + && state.bounds().size.width != bounds.size.width + && (has_selection_snapshot || state.has_view_selection()); ( state.bounds().size != bounds.size, state.selection_adapter.is_part_of_window_selection(cx), - state.selection_adapter.has_selection_snapshot(cx), + has_selection_snapshot, state.is_selecting, - state.compatible_layout_update, + // A measurement prepaint must not consume compatibility + // before the same entity's visible column prepaints. + state.compatible_layout_update + || preserve_width_selection + || state.preserve_inline_selection, + preserve_width_selection, ) }; let mut revision_changed = false; @@ -1126,6 +1138,11 @@ impl Render for TextViewState { revision_changed = state .selection_adapter .update_layout_revision(state.selection_revision, state.is_selecting); + if preserve_width_selection { + // ListState already remeasures width changes. Arm logical + // retention before inline paint without invalidating rows again. + state.preserve_inline_selection = true; + } state.update_bounds(bounds, cx); state.compatible_layout_update = false; }); diff --git a/crates/base/src/text/text_view.rs b/crates/base/src/text/text_view.rs index 449f7cb6bf..f5a461e339 100644 --- a/crates/base/src/text/text_view.rs +++ b/crates/base/src/text/text_view.rs @@ -672,6 +672,15 @@ impl Element for TextView { }); state.update(cx, |state, cx| { + if text_view_style.is_some() + || match (&state.code_block_highlighter, &code_block_highlighter) { + (Some(previous), Some(next)) => !Arc::ptr_eq(previous, next), + (None, None) => false, + _ => true, + } + { + state.preserve_selection_for_reflow(); + } state.code_block_actions = self.code_block_actions.clone(); state.code_block_highlighter = code_block_highlighter; state.table_actions = self.table_actions.clone(); @@ -686,7 +695,6 @@ impl Element for TextView { state.scrollable = self.scrollable; state.max_lines = max_lines; if let Some(text_view_style) = text_view_style { - state.selection_revision = state.selection_revision.wrapping_add(1); state.text_view_style = text_view_style; } diff --git a/crates/component/src/text/window_selection.rs b/crates/component/src/text/window_selection.rs index 8fcec591e3..684fd4490b 100644 --- a/crates/component/src/text/window_selection.rs +++ b/crates/component/src/text/window_selection.rs @@ -1826,7 +1826,7 @@ mod tests { } #[gpui::test] - fn same_size_style_reflow_invalidates_finished_selection(cx: &mut TestAppContext) { + fn same_size_style_reflow_keeps_finished_selection(cx: &mut TestAppContext) { let (chat, cx) = setup(true, cx); drag(cx, point(px(0.), px(15.)), point(px(300.), px(15.))); assert_eq!(window_selected_text(cx).trim(), "Hello world"); @@ -1839,7 +1839,7 @@ mod tests { let _ = window.draw(cx); }); - assert_eq!(window_selected_text(cx), ""); + assert_eq!(window_selected_text(cx).trim(), "Hello world"); } #[gpui::test] diff --git a/crates/kit/Cargo.toml b/crates/kit/Cargo.toml index 9aa963b85a..8d3aed8dd2 100644 --- a/crates/kit/Cargo.toml +++ b/crates/kit/Cargo.toml @@ -199,6 +199,10 @@ required-features = ["assets", "test-support", "component"] name = "touch_selection" required-features = ["test-support", "component"] +[[test]] +name = "text_selection" +required-features = ["test-support", "component"] + [[test]] name = "diff" required-features = ["test-support", "component"] diff --git a/crates/kit/tests/text_selection.rs b/crates/kit/tests/text_selection.rs new file mode 100644 index 0000000000..0bf56a5321 --- /dev/null +++ b/crates/kit/tests/text_selection.rs @@ -0,0 +1,549 @@ +mod common; +use gpui_kit::base::text::{TextView, TextViewState}; +use gpui_kit::{ + AppContext as _, Bounds, Context, Entity, Modifiers, MouseButton, Pixels, Render, + TestAppContext, VisualTestContext, Window, + base::{SelectableText, TextSelection}, + component, div, point, + prelude::*, + px, +}; + +const COMPATIBLE_SELECTION_CODE: &str = + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu"; +const COMPATIBLE_SELECTION_COLOR: u32 = 0x20f0b0; +const COPY_SENTINEL: &str = "NO_TEXT_VIEW_COPY"; + +struct CompatibleSelectionView { + text_view: Entity, + width: Pixels, + top_padding: Pixels, + style: gpui_kit::base::TextViewStyle, + heading_font_size: Pixels, + extensions: component::text::MarkdownExtensions, + show_plain_text: bool, + highlighter_color: Option, + font_size: Option, +} + +impl CompatibleSelectionView { + fn new(cx: &mut Context) -> Self { + let source = format!("# `{COMPATIBLE_SELECTION_CODE}`"); + let style = + gpui_kit::base::TextViewStyle::default().with_inline_code(gpui_kit::HighlightStyle { + background_color: Some(gpui_kit::rgb(COMPATIBLE_SELECTION_COLOR).into()), + ..Default::default() + }); + Self { + text_view: cx.new(|cx| TextViewState::markdown(&source, cx)), + width: px(640.), + top_padding: px(0.), + style, + heading_font_size: px(16.), + extensions: Default::default(), + show_plain_text: false, + highlighter_color: None, + font_size: None, + } + } +} + +impl Render for CompatibleSelectionView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .pt(self.top_padding) + .when_some(self.font_size, |this, font_size| this.text_size(font_size)) + .when(self.show_plain_text, |this| { + this.child( + div() + .h(px(40.)) + .child(SelectableText::new("plain-adapter", "Plain adapter")), + ) + }) + .child( + div().w(self.width).child( + TextView::new(&self.text_view) + .selectable(true) + .style(self.style.clone().with_heading({ + let font_size = self.heading_font_size; + move |_| gpui_kit::StyleRefinement::default().text_size(font_size) + })) + .markdown_extensions(self.extensions.clone()) + .when_some(self.highlighter_color, |this, color| { + this.code_block_highlighter(move |block| { + vec![( + 0..block.code().len(), + gpui_kit::HighlightStyle { + background_color: Some(gpui_kit::rgb(color).into()), + ..Default::default() + }, + )] + }) + }), + ), + ) + } +} + +fn setup_compatible_selection( + cx: &mut TestAppContext, +) -> (Entity, VisualTestContext) { + cx.update(gpui_kit::init); + let (window, view) = + common::open_window(cx, None, |_, cx| cx.new(CompatibleSelectionView::new)); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + (view, cx) +} + +fn painted_background_bounds(color: u32, cx: &mut VisualTestContext) -> Vec> { + cx.update(|window, _| { + let background: gpui_kit::Background = gpui_kit::rgb(color).into(); + let scale_factor = window.scale_factor(); + window + .painted_quads() + .into_iter() + .filter(|quad| quad.background == background) + .map(|quad| quad.bounds.map(|value| px(value.0 / scale_factor))) + .collect() + }) +} + +fn copy_selection(cx: &mut VisualTestContext) -> String { + // TestAppContext's platform owns this clipboard. Poison it for every + // dispatch so an unhandled Copy cannot pass using a previous payload. + cx.update(|window, cx| { + cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string(COPY_SENTINEL.into())); + window.dispatch_action(Box::new(component::input::Copy), cx); + }); + cx.run_until_parked(); + cx.update(|_, cx| { + cx.read_from_clipboard() + .and_then(|item| item.text()) + .unwrap_or_default() + }) +} + +fn select_compatible_partial( + view: &Entity, + cx: &mut VisualTestContext, +) -> ( + gpui_kit::Point, + gpui_kit::Point, + String, + std::ops::Range, +) { + let backgrounds = painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx); + assert_eq!(backgrounds.len(), 1, "initial inline code must fit one row"); + let bounds = backgrounds[0]; + let start = point(bounds.left() + px(20.), bounds.center().y); + let end = point(bounds.left() + px(130.), bounds.center().y); + drag(cx, start, end); + let selected = window_selected_text(cx); + assert!(!selected.trim().is_empty(), "real drag must select text"); + assert_ne!(selected.trim(), COMPATIBLE_SELECTION_CODE); + assert!(COMPATIBLE_SELECTION_CODE.contains(selected.trim())); + assert_eq!(copy_selection(cx), selected.trim()); + let range = view.read_with(cx, |view, cx| { + let state = view.text_view.read(cx); + assert!(!state.is_selecting(), "selection must be ended"); + state.selected_source_range().expect("partial source range") + }); + assert!(range.start > 3, "selection must start inside the code span"); + assert!(range.end < COMPATIBLE_SELECTION_CODE.len() + 3); + (start, end, selected, range) +} + +#[gpui_kit::test] +fn color_style_change_keeps_finished_partial_selection_and_copy(cx: &mut TestAppContext) { + const NEW_COLOR: u32 = 0xf020b0; + const REPLACEMENT: &str = "omega zulu sigma tango upsilon kilo rho bravo echo victor yankee pi"; + let (view, mut test_cx) = setup_compatible_selection(cx); + let cx = &mut test_cx; + let (start, end, selected, range) = select_compatible_partial(&view, cx); + let old_backgrounds = painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx); + let state = view.read_with(cx, |view, _| view.text_view.clone()); + + view.update(cx, |view, cx| { + view.style = view + .style + .clone() + .with_inline_code(gpui_kit::HighlightStyle { + background_color: Some(gpui_kit::rgb(NEW_COLOR).into()), + ..view.style.inline_code() + }); + cx.notify(); + }); + for _ in 0..3 { + view.update(cx, |_, cx| cx.notify()); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert_eq!(window_selected_text(cx), selected); + assert_eq!( + state.read_with(cx, |state, _| state.selected_source_range()), + Some(range.clone()) + ); + assert_eq!(copy_selection(cx), selected.trim()); + assert_eq!(painted_background_bounds(NEW_COLOR, cx), old_backgrounds); + assert!(painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx).is_empty()); + } + + // The policy is presentation-only: a new committed document must not + // keep copying the old range, even when the same Entity stays mounted. + state.update(cx, |state, cx| { + state.set_text(&format!("# `{REPLACEMENT}`"), cx); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert_eq!(window_selected_text(cx), ""); + assert!( + state + .read_with(cx, |state, _| state.selected_source_range()) + .is_none() + ); + assert!(!cx.update(|window, cx| TextSelection::has_selection(window, cx))); + assert_eq!(copy_selection(cx), COPY_SENTINEL); + + drag(cx, start, end); + let replacement_selection = window_selected_text(cx); + assert!(!replacement_selection.trim().is_empty()); + assert!(REPLACEMENT.contains(replacement_selection.trim())); + assert_ne!(replacement_selection, selected); + assert_eq!(copy_selection(cx), replacement_selection.trim()); +} + +#[gpui_kit::test] +fn width_only_narrowing_keeps_finished_partial_selection_and_copy(cx: &mut TestAppContext) { + let (view, mut test_cx) = setup_compatible_selection(cx); + let cx = &mut test_cx; + let (_, _, selected, range) = select_compatible_partial(&view, cx); + let state = view.read_with(cx, |view, _| view.text_view.clone()); + let before = state.read_with(cx, |state, _| state.bounds()); + let old_backgrounds = painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx); + + // No style/font/parser/source update accompanies this narrower layout. + view.update(cx, |view, cx| { + view.width = px(100.); + cx.notify(); + }); + for _ in 0..3 { + view.update(cx, |_, cx| cx.notify()); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert_eq!(window_selected_text(cx), selected); + assert_eq!( + state.read_with(cx, |state, _| state.selected_source_range()), + Some(range.clone()) + ); + assert_eq!(copy_selection(cx), selected.trim()); + } + let after = state.read_with(cx, |state, _| state.bounds()); + assert!(after.size.width < before.size.width); + assert!(after.size.height > before.size.height); + let backgrounds = painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx); + assert!( + backgrounds.len() > 1, + "width alone must actually wrap the code" + ); + assert_eq!( + backgrounds[0].size.height, old_backgrounds[0].size.height, + "the painted font size must remain unchanged" + ); + + // A fresh drag in the wrapped layout replaces the held logical range. + let first_row = backgrounds[0]; + drag( + cx, + point(first_row.left() + px(1.), first_row.center().y), + point(first_row.left() + px(40.), first_row.center().y), + ); + let new_selection = window_selected_text(cx); + assert!(!new_selection.trim().is_empty()); + assert!(COMPATIBLE_SELECTION_CODE.contains(new_selection.trim())); + assert_ne!(new_selection, selected); + assert_ne!( + state.read_with(cx, |state, _| state.selected_source_range()), + Some(range) + ); + assert_eq!(copy_selection(cx), new_selection.trim()); +} + +#[gpui_kit::test] +fn font_and_width_reflow_then_outer_origin_shift_keeps_logical_partial_selection( + cx: &mut TestAppContext, +) { + let (view, mut test_cx) = setup_compatible_selection(cx); + let cx = &mut test_cx; + let (start, end, selected, range) = select_compatible_partial(&view, cx); + let state = view.read_with(cx, |view, _| view.text_view.clone()); + let before = state.read_with(cx, |state, _| state.bounds()); + let old_backgrounds = painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx); + + view.update(cx, |view, cx| { + view.width = px(220.); + view.heading_font_size = px(32.); + cx.notify(); + }); + for _ in 0..3 { + view.update(cx, |_, cx| cx.notify()); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert_eq!(window_selected_text(cx), selected); + assert_eq!( + state.read_with(cx, |state, _| state.selected_source_range()), + Some(range.clone()) + ); + assert_eq!(copy_selection(cx), selected.trim()); + } + let after = state.read_with(cx, |state, _| state.bounds()); + assert!(after.size.width < before.size.width); + assert!(after.size.height > before.size.height); + let backgrounds = painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx); + assert!(backgrounds.len() > 1, "the code must actually wrap"); + assert!( + backgrounds[0].size.height > old_backgrounds[0].size.height * 1.5, + "the new font must reach the painted fragments" + ); + + // Move the mounted consumer from outside TextView only after the font + // reflow has settled. Geometry-only snapshot publication must not turn + // its held logical range back into the pre-reflow pointer rectangle. + let origin_shift = px(48.); + view.update(cx, |view, cx| { + view.top_padding = origin_shift; + cx.notify(); + }); + for _ in 0..3 { + view.update(cx, |_, cx| cx.notify()); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert_eq!(window_selected_text(cx), selected); + assert_eq!( + state.read_with(cx, |state, _| state.selected_source_range()), + Some(range.clone()) + ); + assert_eq!(copy_selection(cx), selected.trim()); + } + let moved = state.read_with(cx, |state, _| state.bounds()); + assert_eq!(moved.origin, after.origin + point(px(0.), origin_shift)); + assert_eq!(moved.size, after.size, "only the outer origin must move"); + let moved_backgrounds = painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx); + assert_eq!(moved_backgrounds.len(), backgrounds.len()); + for (before, moved) in backgrounds.iter().zip(&moved_backgrounds) { + assert_eq!( + moved.origin, + before.origin + point(px(0.), origin_shift), + "the painted fragments must follow the outer layout" + ); + assert_eq!(moved.size, before.size); + } + + // Translate the original pointer rectangle into the moved consumer. + // As a *new* real gesture in the larger font it selects different bytes. + let translation = point(px(0.), origin_shift); + drag(cx, start + translation, end + translation); + let reprojected = window_selected_text(cx); + assert!(!reprojected.trim().is_empty()); + assert_ne!( + reprojected, selected, + "the fixture must distinguish old geometry" + ); + assert_ne!( + state.read_with(cx, |state, _| state.selected_source_range()), + Some(range) + ); + assert_eq!(copy_selection(cx), reprojected.trim()); +} + +#[gpui_kit::test] +fn inherited_font_and_zoom_changes_keep_completed_selection(cx: &mut TestAppContext) { + let (view, mut test_cx) = setup_compatible_selection(cx); + let cx = &mut test_cx; + let state = view.read_with(cx, |view, _| view.text_view.clone()); + state.update(cx, |state, cx| { + state.set_text(&format!("`{COMPATIBLE_SELECTION_CODE}`"), cx) + }); + view.update(cx, |view, cx| { + view.font_size = Some(px(16.)); + cx.notify(); + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let before = painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx); + assert_eq!(before.len(), 1); + drag( + cx, + point(before[0].left() + px(20.), before[0].center().y), + point(before[0].left() + px(130.), before[0].center().y), + ); + let selected = window_selected_text(cx); + assert!(!selected.trim().is_empty()); + let range = state + .read_with(cx, |state, _| state.selected_source_range()) + .unwrap(); + view.update(cx, |view, cx| { + view.font_size = Some(px(32.)); + view.width = px(220.); + cx.notify(); + }); + for _ in 0..3 { + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(window_selected_text(cx), selected); + assert_eq!(copy_selection(cx), selected.trim()); + assert_eq!( + state.read_with(cx, |state, _| state.selected_source_range()), + Some(range.clone()) + ); + } + let after = painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx); + assert!(after.len() > 1); + assert!(after[0].size.height > before[0].size.height); + cx.update(|window, cx| { + component::Theme::update(cx, |theme| theme.font_size = px(24.)); + window.set_rem_size(px(24.)); + cx.notify(view.entity_id()); + }); + for _ in 0..3 { + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(window_selected_text(cx), selected); + assert_eq!(copy_selection(cx), selected.trim()); + } +} + +#[gpui_kit::test] +fn completed_cross_block_selection_survives_reflow_and_clear(cx: &mut TestAppContext) { + let (view, mut test_cx) = setup_compatible_selection(cx); + let cx = &mut test_cx; + let state = view.read_with(cx, |view, _| view.text_view.clone()); + state.update(cx, |state, cx| { + state.set_text( + &format!("# `{COMPATIBLE_SELECTION_CODE}`\n\n# `{COMPATIBLE_SELECTION_CODE}`"), + cx, + ) + }); + cx.run_until_parked(); + for _ in 0..3 { + cx.update(|window, cx| window.draw(cx).clear(cx)); + } + let rows = painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx); + assert_eq!(rows.len(), 2); + drag( + cx, + point(rows[0].left() + px(20.), rows[0].center().y), + point(rows[1].left() + px(130.), rows[1].center().y), + ); + let selected = window_selected_text(cx); + assert!(selected.contains('\n')); + let copied = copy_selection(cx); + view.update(cx, |view, cx| { + view.width = px(180.); + view.heading_font_size = px(24.); + cx.notify(); + }); + for _ in 0..3 { + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(window_selected_text(cx), selected); + assert_eq!(copy_selection(cx), copied); + } + assert!(painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx).len() > 2); + cx.update(|window, cx| TextSelection::clear(window, cx)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(window_selected_text(cx), ""); + assert_eq!(copy_selection(cx), COPY_SENTINEL); +} + +#[gpui_kit::test] +fn completed_cross_renderer_selection_survives_rich_text_reflow(cx: &mut TestAppContext) { + let (view, mut test_cx) = setup_compatible_selection(cx); + let cx = &mut test_cx; + view.update(cx, |view, cx| { + view.show_plain_text = true; + cx.notify(); + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let row = painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx)[0]; + drag( + cx, + point(px(1.), px(15.)), + point(row.left() + px(130.), row.center().y), + ); + let selected = window_selected_text(cx); + assert!(selected.contains("Plain adapter")); + assert!(selected.contains("alpha")); + let copied = copy_selection(cx); + view.update(cx, |view, cx| { + view.width = px(100.); + view.top_padding = px(48.); + view.heading_font_size = px(24.); + cx.notify(); + }); + for _ in 0..3 { + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(window_selected_text(cx), selected); + assert_eq!(copy_selection(cx), copied); + } + assert!(painted_background_bounds(COMPATIBLE_SELECTION_COLOR, cx).len() > 1); +} + +#[gpui_kit::test] +fn highlighter_change_preserves_selection_but_parser_change_clears_it(cx: &mut TestAppContext) { + const HIGHLIGHT: u32 = 0x12abcd; + let (view, mut test_cx) = setup_compatible_selection(cx); + let cx = &mut test_cx; + let state = view.read_with(cx, |view, _| view.text_view.clone()); + state.update(cx, |state, cx| { + state.set_text( + &format!("# `{COMPATIBLE_SELECTION_CODE}`\n\n```rust\nfn main() {{}}\n```"), + cx, + ) + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let (_, _, selected, range) = select_compatible_partial(&view, cx); + view.update(cx, |view, cx| { + view.highlighter_color = Some(HIGHLIGHT); + cx.notify(); + }); + for _ in 0..3 { + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(window_selected_text(cx), selected); + assert_eq!(copy_selection(cx), selected.trim()); + assert_eq!( + state.read_with(cx, |state, _| state.selected_source_range()), + Some(range.clone()) + ); + } + assert!(!painted_background_bounds(HIGHLIGHT, cx).is_empty()); + view.update(cx, |view, cx| { + view.extensions = component::text::MarkdownExtensions::default().parser_revision(1); + cx.notify(); + }); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(window_selected_text(cx), ""); + assert_eq!(copy_selection(cx), COPY_SENTINEL); + assert!( + state + .read_with(cx, |state, _| state.selected_source_range()) + .is_none() + ); +} + +fn window_selected_text(cx: &mut VisualTestContext) -> String { + cx.update(|window, cx| TextSelection::selected_text(window, cx)) +} + +fn drag(cx: &mut VisualTestContext, from: gpui_kit::Point, to: gpui_kit::Point) { + cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_mouse_move(to, Some(MouseButton::Left), Modifiers::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::default()); + cx.update(|window, cx| window.draw(cx).clear(cx)); +} diff --git a/website/base/text-view.md b/website/base/text-view.md index 6f65dcd4ed..e761b4b684 100644 --- a/website/base/text-view.md +++ b/website/base/text-view.md @@ -287,3 +287,7 @@ The live preview and native command use the same Base-only source: ```bash cargo run -p gpui-base-examples -- text-view ``` + +## Selection across presentation changes + +Completed selections retain their logical text range and Copy payload when styles, syntax highlighting, inherited fonts, or width change. Current presentation and wrapping still apply. Moving the mounted view does not reinterpret the old pointer rectangle. A new selection gesture or explicit clear updates the selection normally. Replacing the source or Markdown parser configuration clears the selection. The owning view must keep the state and selection participant mounted; this behavior does not pin rows in an outer virtual list. diff --git a/website/zh-CN/base/text-view.md b/website/zh-CN/base/text-view.md index e69eb2387d..4fda2653e9 100644 --- a/website/zh-CN/base/text-view.md +++ b/website/zh-CN/base/text-view.md @@ -244,3 +244,7 @@ TextView::new(&document).motion( ```bash cargo run -p gpui-base-examples -- text-view ``` + +## 显示变化后的选区保持 + +样式、语法高亮、继承字体或宽度变化时,已完成的选区默认保持相同的逻辑文本范围和复制内容,同时应用新的显示样式和换行布局。移动已挂载的视图不会用旧的鼠标矩形重新计算选区。新的选择操作或显式清除会正常更新选区;替换原文或 Markdown 解析配置仍会清除选区。拥有视图的代码必须保持状态和选择参与者挂载;这个行为不会固定外层虚拟列表中已卸载的行。