From 3186c6dfd03279cc19df894ecea4d6793a982fc9 Mon Sep 17 00:00:00 2001 From: tomixrm <20753105+TomiXRM@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:00:30 +0900 Subject: [PATCH 1/3] feat(text): opt in to logical selection across style changes --- crates/base/README.md | 22 ++ crates/base/src/text/state.rs | 29 ++- crates/base/src/text/text_view.rs | 14 +- crates/component/src/text/window_selection.rs | 232 ++++++++++++++++++ 4 files changed, 290 insertions(+), 7 deletions(-) diff --git a/crates/base/README.md b/crates/base/README.md index 853ec92d11..cd5f1ea807 100644 --- a/crates/base/README.md +++ b/crates/base/README.md @@ -82,6 +82,28 @@ 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 Markdown state can opt into retaining its logical selection while +presentation changes: + +```rust +let body = cx.new(|cx| { + gpui_kit::base::text::TextViewState::markdown("# Hello", cx) + .preserve_selection_on_style_change(true) +}); +``` + +This keeps a completed partial selection and Copy across style, syntax +highlighting, font and width reflow without retaining stale colors or projecting +the old pointer rectangle onto different text. The default remains selection +invalidation on style changes. Replacing the source or parser configuration still +invalidates selection even when this policy is enabled. + +The owning view must retain the state and its painted selection participant. +This policy does not pin an outer virtual-list row after that row is unmounted. + + ## Quick Start Foundation controls can be styled and given children like ordinary GPUI elements: diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index c19c0aa59e..eacefd2d0b 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -119,8 +119,10 @@ pub struct TextViewState { pub(super) link_click_handler: Option>, pub(super) markdown_extensions: Arc, + pub(super) preserve_selection_on_style_change: bool, + 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, @@ -212,6 +214,7 @@ impl TextViewState { link_click_handler: None, image_source: None, markdown_extensions: Arc::default(), + preserve_selection_on_style_change: false, is_selecting: false, preserve_inline_selection: false, auto_scroll: AutoScroll::default(), @@ -257,6 +260,14 @@ impl TextViewState { cx.notify(); } + /// Retain logical selection when styles or syntax highlighting change, + /// default false. Source or parser configuration replacements still + /// invalidate selection. + pub fn preserve_selection_on_style_change(mut self, preserve: bool) -> Self { + self.preserve_selection_on_style_change = preserve; + self + } + /// Set the [`SelectionFormat`], default is [`SelectionFormat::Plain`]. pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self { self.selection_format = selection_format; @@ -477,10 +488,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) { @@ -1027,9 +1042,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(); @@ -1097,7 +1110,11 @@ impl Render for TextViewState { state.selection_adapter.is_part_of_window_selection(cx), state.selection_adapter.has_selection_snapshot(cx), 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 + || (state.preserve_selection_on_style_change + && state.preserve_inline_selection), ) }; let mut revision_changed = false; diff --git a/crates/base/src/text/text_view.rs b/crates/base/src/text/text_view.rs index d7454082f8..d3a62c32ea 100644 --- a/crates/base/src/text/text_view.rs +++ b/crates/base/src/text/text_view.rs @@ -672,6 +672,16 @@ impl Element for TextView { }); state.update(cx, |state, cx| { + if state.preserve_selection_on_style_change + && (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 +696,9 @@ 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); + if !state.preserve_selection_on_style_change { + 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 799bb30dd4..b5c84f57af 100644 --- a/crates/component/src/text/window_selection.rs +++ b/crates/component/src/text/window_selection.rs @@ -435,6 +435,238 @@ mod tests { (chat, cx) } + 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, + style: crate::text::TextViewStyle, + } + + impl CompatibleSelectionView { + fn new(cx: &mut Context) -> Self { + let source = format!("# `{COMPATIBLE_SELECTION_CODE}`"); + let mut style = crate::text::TextViewStyle::default() + .heading_font_size(|_, size| size) + .inline_code(gpui::HighlightStyle { + background_color: Some(gpui::rgb(COMPATIBLE_SELECTION_COLOR).into()), + ..Default::default() + }); + style.heading_base_font_size = px(16.); + Self { + text_view: cx.new(|cx| { + TextViewState::markdown(&source, cx).preserve_selection_on_style_change(true) + }), + width: px(640.), + style, + } + } + } + + impl Render for CompatibleSelectionView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child( + div().w(self.width).child( + TextView::new(&self.text_view) + .selectable(true) + .style(self.style.clone()), + ), + ) + } + } + + fn setup_compatible_selection( + cx: &mut TestAppContext, + ) -> (Entity, &mut VisualTestContext) { + cx.update(crate::init); + let (root, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(CompatibleSelectionView::new); + Root::new(view, window, cx) + }); + let view = root.read_with(cx, |root, _| { + root.view() + .clone() + .downcast::() + .unwrap() + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + (view, cx) + } + + fn painted_background_bounds(color: u32, cx: &mut VisualTestContext) -> Vec> { + cx.update(|window, _| { + let background: gpui::Background = gpui::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::ClipboardItem::new_string(COPY_SENTINEL.into())); + window.dispatch_action(Box::new(crate::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::Point, + gpui::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::test] + fn opted_in_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, cx) = setup_compatible_selection(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.inline_code.background_color = Some(gpui::rgb(NEW_COLOR).into()); + 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::test] + fn opted_in_font_and_width_reflow_keeps_logical_partial_selection_not_old_geometry( + cx: &mut TestAppContext, + ) { + let (view, cx) = setup_compatible_selection(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.style.heading_base_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" + ); + + // Reusing the old coordinates as a *new* real gesture proves the + // preserved result could not have come from geometry reprojection. + drag(cx, start, end); + 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::test] fn base_plain_selection_and_text_view_share_one_cross_renderer_selection( cx: &mut TestAppContext, From 6c783a20af3be05a376a2f28955bc898a19ad233 Mon Sep 17 00:00:00 2001 From: tomixrm <20753105+TomiXRM@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:26:10 +0900 Subject: [PATCH 2/3] fix(text): retain opted-in ranges on width and origin changes --- crates/base/README.md | 15 ++- crates/base/src/text/selection_adapter.rs | 20 +++- crates/base/src/text/state.rs | 15 ++- crates/component/src/text/window_selection.rs | 105 +++++++++++++++++- 4 files changed, 140 insertions(+), 15 deletions(-) diff --git a/crates/base/README.md b/crates/base/README.md index cd5f1ea807..704ac084ac 100644 --- a/crates/base/README.md +++ b/crates/base/README.md @@ -94,11 +94,16 @@ let body = cx.new(|cx| { }); ``` -This keeps a completed partial selection and Copy across style, syntax -highlighting, font and width reflow without retaining stale colors or projecting -the old pointer rectangle onto different text. The default remains selection -invalidation on style changes. Replacing the source or parser configuration still -invalidates selection even when this policy is enabled. +The opt-in policy retains the logical byte range of a completed partial selection +and its Copy payload across style, syntax highlighting, font reflow, and width-only +reflow. Later outer-layout origin changes do not reinterpret that retained range +using the old pointer rectangle. New pointer selection gestures still select the +characters at their current painted positions; presentation continues to repaint +with current styles rather than retaining stale colors. + +The policy is disabled by default, preserving the existing selection invalidation +behavior. Replacing the source or parser configuration still invalidates selection +even when this policy is enabled. The owning view must retain the state and its painted selection participant. This policy does not pin an outer virtual-list row after that row is unmounted. diff --git a/crates/base/src/text/selection_adapter.rs b/crates/base/src/text/selection_adapter.rs index b3bf12cfaa..fba27b9008 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( @@ -94,10 +104,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 eacefd2d0b..cedf6b601e 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -1103,18 +1103,26 @@ 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.preserve_selection_on_style_change + && !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, // A measurement prepaint must not consume compatibility // before the same entity's visible column prepaints. state.compatible_layout_update + || preserve_width_selection || (state.preserve_selection_on_style_change && state.preserve_inline_selection), + preserve_width_selection, ) }; let mut revision_changed = false; @@ -1122,6 +1130,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/component/src/text/window_selection.rs b/crates/component/src/text/window_selection.rs index b5c84f57af..82bb0088af 100644 --- a/crates/component/src/text/window_selection.rs +++ b/crates/component/src/text/window_selection.rs @@ -443,6 +443,7 @@ mod tests { struct CompatibleSelectionView { text_view: Entity, width: Pixels, + top_padding: Pixels, style: crate::text::TextViewStyle, } @@ -461,6 +462,7 @@ mod tests { TextViewState::markdown(&source, cx).preserve_selection_on_style_change(true) }), width: px(640.), + top_padding: px(0.), style, } } @@ -468,7 +470,7 @@ mod tests { impl Render for CompatibleSelectionView { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div().size_full().child( + div().size_full().pt(self.top_padding).child( div().w(self.width).child( TextView::new(&self.text_view) .selectable(true) @@ -615,7 +617,65 @@ mod tests { } #[gpui::test] - fn opted_in_font_and_width_reflow_keeps_logical_partial_selection_not_old_geometry( + fn opted_in_width_only_narrowing_keeps_finished_partial_selection_and_copy( + cx: &mut TestAppContext, + ) { + let (view, cx) = setup_compatible_selection(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::test] + fn opted_in_font_and_width_reflow_then_outer_origin_shift_keeps_logical_partial_selection( cx: &mut TestAppContext, ) { let (view, cx) = setup_compatible_selection(cx); @@ -651,9 +711,44 @@ mod tests { "the new font must reach the painted fragments" ); - // Reusing the old coordinates as a *new* real gesture proves the - // preserved result could not have come from geometry reprojection. - drag(cx, start, end); + // 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!( From 48cc5389a9eb2f21c49f679d9235291e85d66b79 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 9 Oct 2026 21:26:54 +0800 Subject: [PATCH 3/3] text: Preserve selection across presentation changes by default --- crates/base/README.md | 34 +- crates/base/src/text/state.rs | 17 +- crates/base/src/text/text_view.rs | 16 +- crates/component/src/text/window_selection.rs | 331 +---------- crates/kit/Cargo.toml | 4 + crates/kit/tests/text_selection.rs | 549 ++++++++++++++++++ website/base/text-view.md | 4 + website/zh-CN/base/text-view.md | 4 + 8 files changed, 581 insertions(+), 378 deletions(-) create mode 100644 crates/kit/tests/text_selection.rs diff --git a/crates/base/README.md b/crates/base/README.md index 704ac084ac..f9eca97e4f 100644 --- a/crates/base/README.md +++ b/crates/base/README.md @@ -84,30 +84,16 @@ If the application already calls `gpui_kit::init(cx)`, do not call `gpui_kit::ba ## Retained Rich-Text Selection -Managed Markdown state can opt into retaining its logical selection while -presentation changes: - -```rust -let body = cx.new(|cx| { - gpui_kit::base::text::TextViewState::markdown("# Hello", cx) - .preserve_selection_on_style_change(true) -}); -``` - -The opt-in policy retains the logical byte range of a completed partial selection -and its Copy payload across style, syntax highlighting, font reflow, and width-only -reflow. Later outer-layout origin changes do not reinterpret that retained range -using the old pointer rectangle. New pointer selection gestures still select the -characters at their current painted positions; presentation continues to repaint -with current styles rather than retaining stale colors. - -The policy is disabled by default, preserving the existing selection invalidation -behavior. Replacing the source or parser configuration still invalidates selection -even when this policy is enabled. - -The owning view must retain the state and its painted selection participant. -This policy does not pin an outer virtual-list row after that row is unmounted. - +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 diff --git a/crates/base/src/text/state.rs b/crates/base/src/text/state.rs index cedf6b601e..8260d42c38 100644 --- a/crates/base/src/text/state.rs +++ b/crates/base/src/text/state.rs @@ -119,8 +119,6 @@ pub struct TextViewState { pub(super) link_click_handler: Option>, pub(super) markdown_extensions: Arc, - pub(super) preserve_selection_on_style_change: bool, - pub(super) is_selecting: bool, /// Logical ranges retained across compatible resource or presentation reflow. pub(super) preserve_inline_selection: bool, @@ -214,7 +212,6 @@ impl TextViewState { link_click_handler: None, image_source: None, markdown_extensions: Arc::default(), - preserve_selection_on_style_change: false, is_selecting: false, preserve_inline_selection: false, auto_scroll: AutoScroll::default(), @@ -260,14 +257,6 @@ impl TextViewState { cx.notify(); } - /// Retain logical selection when styles or syntax highlighting change, - /// default false. Source or parser configuration replacements still - /// invalidate selection. - pub fn preserve_selection_on_style_change(mut self, preserve: bool) -> Self { - self.preserve_selection_on_style_change = preserve; - self - } - /// Set the [`SelectionFormat`], default is [`SelectionFormat::Plain`]. pub fn selection_format(mut self, selection_format: SelectionFormat) -> Self { self.selection_format = selection_format; @@ -1107,8 +1096,7 @@ impl Render for TextViewState { ) = { let state = state.read(cx); let has_selection_snapshot = state.selection_adapter.has_selection_snapshot(cx); - let preserve_width_selection = state.preserve_selection_on_style_change - && !state.is_selecting + let preserve_width_selection = !state.is_selecting && state.bounds().size.width != bounds.size.width && (has_selection_snapshot || state.has_view_selection()); ( @@ -1120,8 +1108,7 @@ impl Render for TextViewState { // before the same entity's visible column prepaints. state.compatible_layout_update || preserve_width_selection - || (state.preserve_selection_on_style_change - && state.preserve_inline_selection), + || state.preserve_inline_selection, preserve_width_selection, ) }; diff --git a/crates/base/src/text/text_view.rs b/crates/base/src/text/text_view.rs index d3a62c32ea..0865525a83 100644 --- a/crates/base/src/text/text_view.rs +++ b/crates/base/src/text/text_view.rs @@ -672,13 +672,12 @@ impl Element for TextView { }); state.update(cx, |state, cx| { - if state.preserve_selection_on_style_change - && (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, - }) + 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(); } @@ -696,9 +695,6 @@ impl Element for TextView { state.scrollable = self.scrollable; state.max_lines = max_lines; if let Some(text_view_style) = text_view_style { - if !state.preserve_selection_on_style_change { - 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 82bb0088af..8b94bf1851 100644 --- a/crates/component/src/text/window_selection.rs +++ b/crates/component/src/text/window_selection.rs @@ -435,333 +435,6 @@ mod tests { (chat, cx) } - 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: crate::text::TextViewStyle, - } - - impl CompatibleSelectionView { - fn new(cx: &mut Context) -> Self { - let source = format!("# `{COMPATIBLE_SELECTION_CODE}`"); - let mut style = crate::text::TextViewStyle::default() - .heading_font_size(|_, size| size) - .inline_code(gpui::HighlightStyle { - background_color: Some(gpui::rgb(COMPATIBLE_SELECTION_COLOR).into()), - ..Default::default() - }); - style.heading_base_font_size = px(16.); - Self { - text_view: cx.new(|cx| { - TextViewState::markdown(&source, cx).preserve_selection_on_style_change(true) - }), - width: px(640.), - top_padding: px(0.), - style, - } - } - } - - impl Render for CompatibleSelectionView { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div().size_full().pt(self.top_padding).child( - div().w(self.width).child( - TextView::new(&self.text_view) - .selectable(true) - .style(self.style.clone()), - ), - ) - } - } - - fn setup_compatible_selection( - cx: &mut TestAppContext, - ) -> (Entity, &mut VisualTestContext) { - cx.update(crate::init); - let (root, cx) = cx.add_window_view(|window, cx| { - let view = cx.new(CompatibleSelectionView::new); - Root::new(view, window, cx) - }); - let view = root.read_with(cx, |root, _| { - root.view() - .clone() - .downcast::() - .unwrap() - }); - cx.run_until_parked(); - cx.update(|window, cx| { - let _ = window.draw(cx); - }); - (view, cx) - } - - fn painted_background_bounds(color: u32, cx: &mut VisualTestContext) -> Vec> { - cx.update(|window, _| { - let background: gpui::Background = gpui::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::ClipboardItem::new_string(COPY_SENTINEL.into())); - window.dispatch_action(Box::new(crate::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::Point, - gpui::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::test] - fn opted_in_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, cx) = setup_compatible_selection(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.inline_code.background_color = Some(gpui::rgb(NEW_COLOR).into()); - 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::test] - fn opted_in_width_only_narrowing_keeps_finished_partial_selection_and_copy( - cx: &mut TestAppContext, - ) { - let (view, cx) = setup_compatible_selection(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::test] - fn opted_in_font_and_width_reflow_then_outer_origin_shift_keeps_logical_partial_selection( - cx: &mut TestAppContext, - ) { - let (view, cx) = setup_compatible_selection(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.style.heading_base_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::test] fn base_plain_selection_and_text_view_share_one_cross_renderer_selection( cx: &mut TestAppContext, @@ -2151,7 +1824,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"); @@ -2164,7 +1837,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 ed469cfc59..613072694e 100644 --- a/crates/kit/Cargo.toml +++ b/crates/kit/Cargo.toml @@ -159,3 +159,7 @@ required-features = ["assets", "test-support", "component"] [[test]] name = "touch_selection" required-features = ["test-support", "component"] + +[[test]] +name = "text_selection" +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 解析配置仍会清除选区。拥有视图的代码必须保持状态和选择参与者挂载;这个行为不会固定外层虚拟列表中已卸载的行。