Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/base/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 16 additions & 4 deletions crates/base/src/text/selection_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,29 @@ struct VirtualBlockSelection {
anchor: Option<CachedBlockEndpoint>,
cursor: Option<CachedBlockEndpoint>,
coverage: TextSelectionCoverage,
is_selecting: bool,
}

impl VirtualBlockSelection {
fn update(&mut self, snapshot: Option<TextSelectionSnapshot>, entity_id: EntityId) {
fn update(&mut self, snapshot: Option<TextSelectionSnapshot>, 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(
Expand Down Expand Up @@ -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();
Expand Down
31 changes: 24 additions & 7 deletions crates/base/src/text/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ pub struct TextViewState {
pub(super) markdown_extensions: Arc<MarkdownExtensions>,

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<TextViewMultiClickSelection>,
selected_text_override: Option<String>,
Expand Down Expand Up @@ -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>) {
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<Self>) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1111,21 +1113,36 @@ 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;
state.update(cx, |state, cx| {
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;
});
Expand Down
10 changes: 9 additions & 1 deletion crates/base/src/text/text_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions crates/component/src/text/window_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions crates/kit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
gpui-kit-assets = { workspace = true, optional = true }

[target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies]
gpui_platform.workspace = true

Check warning on line 82 in crates/kit/Cargo.toml

View workflow job for this annotation

GitHub Actions / GPUI Fast (WebAssembly)

unused dependency `gpui_platform`
gpui-fast-platform = { workspace = true, optional = true }

[target.'cfg(target_family = "wasm")'.dependencies]
Expand Down Expand Up @@ -199,6 +199,10 @@
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"]
Loading
Loading