diff --git a/Cargo.lock b/Cargo.lock index dcb992b2..6e3ea3b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6446,6 +6446,7 @@ dependencies = [ "futures-channel", "futures-lite", "getrandom 0.4.3", + "gpui-base", "gpui-component", "gpui-kit-assets", "gpui-pre", diff --git a/crates/gui/Cargo.toml b/crates/gui/Cargo.toml index 6d06e057..61f248a6 100644 --- a/crates/gui/Cargo.toml +++ b/crates/gui/Cargo.toml @@ -34,6 +34,7 @@ chrono = { workspace = true, features = ["std", "clock"] } # Match GPUI Kit's published GPUI family so entities share one implementation. gpui = { package = "gpui-pre", version = "0.3.4" } +gpui-base = { git = "https://github.com/longbridge/gpui-kit.git" } gpui-component = { git = "https://github.com/longbridge/gpui-kit.git" } gpui-kit-assets = { git = "https://github.com/longbridge/gpui-kit.git" } diff --git a/crates/gui/src/app/body.rs b/crates/gui/src/app/body.rs index 910b5c42..47289506 100644 --- a/crates/gui/src/app/body.rs +++ b/crates/gui/src/app/body.rs @@ -61,6 +61,9 @@ impl Render for Body { } AppState::Syncing => render_syncing_view(cx).into_any_element(), AppState::Connected | AppState::Offline if app.showing_settings(cx) => { + if gpui_base::TextSelection::has_selection(window, cx) { + super::clear_window_message_selection(window, cx); + } render_settings_view(app, window, cx).into_any_element() } AppState::Connected | AppState::Offline => { diff --git a/crates/gui/src/app/chats.rs b/crates/gui/src/app/chats.rs index cb21385e..19f6b6c0 100644 --- a/crates/gui/src/app/chats.rs +++ b/crates/gui/src/app/chats.rs @@ -262,7 +262,7 @@ impl WhatsAppApp { // A selection that no longer names a chat is a selection of nothing: // the conversation pane resolves it every frame and would draw the // empty state with no way back on a phone. - self.forget_missing_selection(); + self.forget_missing_selection(cx); // The merge above took the store's word for every row, and a merge // assembled before a view was written does not carry it. self.restore_watched_status(watched); diff --git a/crates/gui/src/app/events.rs b/crates/gui/src/app/events.rs index c462c29a..805fcf59 100644 --- a/crates/gui/src/app/events.rs +++ b/crates/gui/src/app/events.rs @@ -231,7 +231,7 @@ impl WhatsAppApp { cx.notify(); } UiEvent::LoggedOut(message) => { - self.leave_connected_view(cx); + self.leave_connected_view(None, cx); self.app_state = AppState::LoggedOut { message }; cx.notify(); } @@ -318,6 +318,13 @@ impl WhatsAppApp { self.handle_chat_presence(chat_jid, sender_jid, sender_name, composing, cx); } UiEvent::AccountUpdated { name, jid, lid } => { + if (self.account_jid != jid || self.account_lid != lid) + && let Some(window) = self.modal_window + { + let _ = window.update(cx, |_, window, cx| { + super::clear_window_message_selection(window, cx); + }); + } if self.account_name != name || self.account_jid != jid || self.account_lid != lid { self.account_name = name; self.account_jid = jid; diff --git a/crates/gui/src/app/messages.rs b/crates/gui/src/app/messages.rs index 7413cce1..648a7d39 100644 --- a/crates/gui/src/app/messages.rs +++ b/crates/gui/src/app/messages.rs @@ -108,6 +108,9 @@ pub struct BubbleIds { pub group: SharedString, /// The bubble itself. pub bubble: SharedString, + /// Shared selection identity, built with the row cache rather than on each + /// bubble repaint. + pub selection_key: Arc, /// The three controls in the action bar beside it, which is built on /// every frame whether or not the pointer is over the row: it is /// `invisible()` until a hover reveals it, not absent. @@ -125,6 +128,7 @@ impl BubbleIds { row: format!("row-{id}").into(), group: format!("bubble-{id}").into(), bubble: format!("msg-{id}").into(), + selection_key: Arc::from(id.as_str()), react: format!("react-{id}").into(), reply: format!("reply-{id}").into(), copy: format!("copy-{id}").into(), @@ -279,6 +283,16 @@ mod tests { msg } + #[test] + fn bubble_clones_share_the_precomputed_selection_identity() { + let message = message("peer", false, at(13, 9)); + let ids = BubbleIds::of(&message); + let cloned_ids = ids.clone(); + + assert_eq!(ids.selection_key.as_ref(), message.id.as_str()); + assert!(Arc::ptr_eq(&ids.selection_key, &cloned_ids.selection_key)); + } + fn kinds(items: &[TimelineItem]) -> Vec<&'static str> { items .iter() diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index 0110f198..8f1a8f34 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -1051,6 +1051,18 @@ impl IncomingMessageMetadata { } } +pub(super) fn clear_window_message_selection(window: &mut Window, cx: &mut App) { + gpui_base::TextSelection::clear(window, cx); + crate::components::rich_text_selection::forget_window_selection_registry( + window.window_handle().window_id(), + cx, + ); +} + +fn message_has_selectable_text(message: &ChatMessage) -> bool { + message.system.is_none() && message.poll.is_none() && !message.content.is_empty() +} + impl WhatsAppApp { pub fn media_cache(&self) -> Option> { self.client.as_ref().map(Session::media_cache) @@ -1207,9 +1219,18 @@ impl WhatsAppApp { // re-pair — its name under the sidebar, its number // beneath, and its JID still reading as "(You)" — // until some later `AccountUpdated` corrected it. + let jid = account.as_ref().and_then(|a| a.jid.clone()); + let lid = account.as_ref().and_then(|a| a.lid.clone()); + if (app.account_jid != jid || app.account_lid != lid) + && let Some(window) = app.modal_window + { + let _ = window.update(cx, |_, window, cx| { + clear_window_message_selection(window, cx); + }); + } app.account_name = account.as_ref().and_then(|a| a.name.clone()); - app.account_jid = account.as_ref().and_then(|a| a.jid.clone()); - app.account_lid = account.and_then(|a| a.lid); + app.account_jid = jid; + app.account_lid = lid; cx.notify(); }), FromDaemon::Avatar { jid, key } => entity.update(cx, |app, cx| { @@ -1901,6 +1922,84 @@ impl WhatsAppApp { /// same messages: every caller here is announcing that the chat's history /// changed, which is exactly when the search's matches stop describing it. fn invalidate_message_cache(&mut self, chat_jid: &str, cx: &mut App) { + if self.selected_chat.as_deref() == Some(chat_jid) + && let Some(window) = self.modal_window + { + let selected_messages = + crate::components::rich_text_selection::active_selection_message_snapshots( + window.window_id(), + cx, + ); + let selected_ids: Vec = selected_messages + .iter() + .map(|(message_id, _, _)| message_id.clone()) + .collect(); + let selection_is_stale = if selected_ids.is_empty() { + false + } else { + let cache = self.message_list_cache.borrow(); + match (cache.get(chat_jid), self.find_chat(chat_jid)) { + (Some(previous), Some(current)) => { + let previous_by_id: HashMap<&str, (usize, &ChatMessage)> = previous + .messages + .iter() + .enumerate() + .map(|(index, message)| { + (message.id.as_str(), (index, message.as_ref())) + }) + .collect(); + let current_by_id: HashMap<&str, (usize, &ChatMessage)> = current + .messages + .iter() + .enumerate() + .map(|(index, message)| (message.id.as_str(), (index, message))) + .collect(); + selected_ids.iter().any(|message_id| { + let (Some((old_index, old)), Some((new_index, new))) = ( + previous_by_id.get(message_id.as_str()), + current_by_id.get(message_id.as_str()), + ) else { + return true; + }; + old_index != new_index + || !message_has_selectable_text(old) + || !message_has_selectable_text(new) + || old.content != new.content + || old.revoked != new.revoked + }) + } + (None, Some(current)) => { + // The render cache may already be gone, but retained + // participants keep the visible text to compare against. + let current_by_id: HashMap<&str, (usize, &ChatMessage)> = current + .messages + .iter() + .enumerate() + .map(|(index, message)| (message.id.as_str(), (index, message))) + .collect(); + selected_messages.iter().any( + |(message_id, selected_text, selected_order)| { + let Some((current_order, message)) = + current_by_id.get(message_id.as_str()) + else { + return true; + }; + *current_order as u64 != *selected_order + || !message_has_selectable_text(message) + || crate::components::BubbleText::of(&message.content).text() + != selected_text.as_ref() + }, + ) + } + _ => true, + } + }; + if selection_is_stale { + let _ = window.update(cx, |_, window, cx| { + clear_window_message_selection(window, cx); + }); + } + } self.message_list_cache.borrow_mut().remove(chat_jid); self.refresh_conversation_search(chat_jid, cx); self.refresh_media_viewer(chat_jid, cx); @@ -1918,8 +2017,22 @@ impl WhatsAppApp { /// exactly when its timeline needs filling, and the frame that draws it /// is the one place that knows which chat that is — the selection can /// name a chat the window is not showing (Settings is up, the reader is - /// in Status). - pub fn note_visible_conversation(&mut self, jid: Option, cx: &mut App) { + /// in Status). Leaving that visible conversation clears its message-text + /// selection so the hidden pane cannot keep answering Copy. + pub fn note_visible_conversation( + &mut self, + jid: Option, + window: &mut Window, + cx: &mut Context, + ) { + if self + .retained_chat + .as_deref() + .is_some_and(|previous| jid.as_deref() != Some(previous)) + && gpui_base::TextSelection::has_selection(window, cx) + { + clear_window_message_selection(window, cx); + } if let Some(jid) = &jid { self.ensure_timeline_page(jid, cx); } @@ -2026,7 +2139,7 @@ impl WhatsAppApp { // controls to stop it, and an encode that could still finish, pass an // epoch nothing had bumped, and send the old account's note from the // newly paired one. - self.leave_connected_view(cx); + self.leave_connected_view(Some(window), cx); self.incoming_file_epoch = self.incoming_file_epoch.wrapping_add(1); self.incoming_file_reading = false; self.paste_preview = None; @@ -2123,7 +2236,18 @@ impl WhatsAppApp { /// /// Not [`AppState::Offline`]: that keeps the conversation on screen and /// only refuses to send. - fn leave_connected_view(&mut self, cx: &mut Context) { + fn leave_connected_view(&mut self, window: Option<&mut Window>, cx: &mut Context) { + // The window-scoped text selection outlives the conversation's rows. + // Clear it with the rest of the connected view so Copy cannot expose + // hidden message text after a disconnect, error, or logout. Account + // teardown already has the Window; other transitions use its handle. + if let Some(window) = window { + clear_window_message_selection(window, cx); + } else if let Some(window) = self.modal_window { + let _ = window.update(cx, |_, window, cx| { + clear_window_message_selection(window, cx); + }); + } if self.recorder.read(cx).state() != RecordingState::Idle { self.cancel_recording(cx); } @@ -2393,7 +2517,7 @@ impl WhatsAppApp { // typed, let alone sent. self.drafts.remove(jid); } - self.forget_missing_selection(); + self.forget_missing_selection(cx); // The viewer names a chat and a message in it, and resolves them every // frame: one left open over a chat that has just gone draws nothing, // keeps the keyboard, and swallows the Escape meant to close it. @@ -2408,12 +2532,17 @@ impl WhatsAppApp { /// pointing at a deleted chat draws the empty state — and on a phone that /// is the whole screen, with the Back button belonging to a conversation /// that is not there. - fn forget_missing_selection(&mut self) { + fn forget_missing_selection(&mut self, cx: &mut App) { if self .selected_chat .as_deref() .is_some_and(|jid| !self.chats.iter().any(|chat| chat.jid == jid)) { + if let Some(window) = self.modal_window { + let _ = window.update(cx, |_, window, cx| { + clear_window_message_selection(window, cx); + }); + } self.selected_chat = None; } } @@ -2599,6 +2728,11 @@ impl WhatsAppApp { // on this target, where the prompt is the browser's rather than the // operating system's. crate::platform::request_gesture_authorization(); + if self.selected_chat.as_deref() != Some(jid.as_str()) { + // Message-text selection belongs to the conversation being left, + // not to the next list reusing the window selection layer. + clear_window_message_selection(window, cx); + } self.stop_current_media(); // Leaving a chat mid-composition: release its typing indicator now, // or it would stay "typing..." and the eventual paused would land on @@ -4190,6 +4324,172 @@ fn timeline_may_page(visible: Option<&str>, anchored: Option<&str>, chat_jid: &s mod tests { use super::*; + struct SelectionExitTestView { + focus_handle: FocusHandle, + selection_key: Arc, + } + + impl Render for SelectionExitTestView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let text = crate::components::BubbleText::of("alpha beta"); + div().track_focus(&self.focus_handle).size_full().child( + div() + .id("message-row") + .w(gpui::px(400.)) + .h(gpui::px(40.)) + .child(crate::components::render_rich_text( + &text, + self.selection_key.clone(), + 0, + cx, + )), + ) + } + } + + #[gpui::test] + fn repeated_cache_misses_preserve_unchanged_message_selection(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + let jid = "peer@example.invalid"; + let mut focus_handle = None; + let mut app_entity = None; + let (_, visual) = cx.add_window_view(|window, cx| { + let app = cx.new(|cx| { + let mut app = WhatsAppApp::new(cx); + let mut chat = Chat::new(jid.to_string()); + chat.messages.push(ChatMessage::new_incoming( + "test-message".into(), + jid.into(), + "alpha beta".into(), + )); + app.chats.push(Arc::new(chat)); + app.selected_chat = Some(jid.to_string()); + app + }); + app.update(cx, |app, _| app.set_modal_window(window.window_handle())); + app_entity = Some(app); + let view = cx.new(|cx| { + let handle = cx.focus_handle(); + focus_handle = Some(handle.clone()); + SelectionExitTestView { + focus_handle: handle, + selection_key: Arc::from("test-message"), + } + }); + gpui_component::Root::new(view, window, cx) + }); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + focus_handle.as_ref().unwrap().focus(window, cx); + }); + visual.simulate_mouse_down( + gpui::point(gpui::px(1.), gpui::px(12.)), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + visual.simulate_mouse_move( + gpui::point(gpui::px(58.), gpui::px(12.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + visual.simulate_mouse_up( + gpui::point(gpui::px(58.), gpui::px(12.)), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + assert_eq!( + visual.update(|window, cx| { + window.draw(cx).clear(cx); + gpui_base::TextSelection::selected_text(window, cx) + }), + "alpha " + ); + + visual.update(|window, cx| { + app_entity.as_ref().unwrap().update(cx, |app, cx| { + // Model the call-history path: two updates before another list + // frame leaves the render cache absent both times. + app.invalidate_message_cache(jid, cx); + app.invalidate_message_cache(jid, cx); + }); + window.draw(cx).clear(cx); + }); + assert_eq!( + visual.update(|window, cx| { + window.draw(cx).clear(cx); + gpui_base::TextSelection::selected_text(window, cx) + }), + "alpha " + ); + } + + #[gpui::test] + fn leaving_connected_view_clears_selected_message_text(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + let mut focus_handle = None; + let mut app_entity = None; + let (_, visual) = cx.add_window_view(|window, cx| { + let app = cx.new(WhatsAppApp::new); + app.update(cx, |app, _| app.set_modal_window(window.window_handle())); + app_entity = Some(app); + let view = cx.new(|cx| { + let handle = cx.focus_handle(); + focus_handle = Some(handle.clone()); + SelectionExitTestView { + focus_handle: handle, + selection_key: Arc::from("test-message"), + } + }); + gpui_component::Root::new(view, window, cx) + }); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + focus_handle.as_ref().unwrap().focus(window, cx); + }); + visual.simulate_mouse_down( + gpui::point(gpui::px(1.), gpui::px(12.)), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + visual.simulate_mouse_move( + gpui::point(gpui::px(58.), gpui::px(12.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + visual.simulate_mouse_up( + gpui::point(gpui::px(58.), gpui::px(12.)), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + assert_eq!( + visual.update(|window, cx| { + window.draw(cx).clear(cx); + gpui_base::TextSelection::selected_text(window, cx) + }), + "alpha " + ); + + visual.update(|window, cx| { + app_entity + .as_ref() + .unwrap() + .update(cx, |app, cx| app.leave_connected_view(Some(window), cx)); + }); + assert_eq!( + visual.update(|window, cx| { + window.draw(cx).clear(cx); + gpui_base::TextSelection::selected_text(window, cx) + }), + "" + ); + } + #[test] fn a_sent_receipt_replaces_only_the_outgoing_pending_clock() { assert_eq!(receipt_status(ReceiptType::Sent), Some(MessageStatus::Sent)); diff --git a/crates/gui/src/app/paging.rs b/crates/gui/src/app/paging.rs index 71a6f5fa..7b72e7d1 100644 --- a/crates/gui/src/app/paging.rs +++ b/crates/gui/src/app/paging.rs @@ -455,9 +455,9 @@ impl WhatsAppApp { if !moved { return; } - // The rows moved, and the timeline's own measurements are keyed to - // them: see `sync_timeline`, which is what turns this into a splice at - // the front rather than a reset to the bottom. + // Invalidation compares selected identities and order against either + // the render cache or retained snapshots: a prepend clears stale order, + // while a receipt-only update to an existing row preserves selection. self.invalidate_message_cache(&jid, cx); self.invalidate_chat_cache(); cx.notify(); @@ -560,7 +560,7 @@ impl WhatsAppApp { return; } self.forget_chat_paging(&dropped, cx); - self.forget_missing_selection(); + self.forget_missing_selection(cx); self.invalidate_chat_cache(); cx.notify(); } diff --git a/crates/gui/src/app/recovery.rs b/crates/gui/src/app/recovery.rs index 01d61e78..e74987d1 100644 --- a/crates/gui/src/app/recovery.rs +++ b/crates/gui/src/app/recovery.rs @@ -154,7 +154,7 @@ impl WhatsAppApp { /// fail the same way forever, so arming a retry is a promise this cannot /// keep. pub(super) fn connection_ended(&mut self, fault: Fault, cx: &mut Context) { - self.leave_connected_view(cx); + self.leave_connected_view(None, cx); let recovery = fault.recovery; // A new fault is a new sentence, and the fold under it belongs to the // one it replaces. See `Recovering::close_detail`. diff --git a/crates/gui/src/components/message_bubble/mod.rs b/crates/gui/src/components/message_bubble/mod.rs index dd3fe90b..41656b7d 100644 --- a/crates/gui/src/components/message_bubble/mod.rs +++ b/crates/gui/src/components/message_bubble/mod.rs @@ -60,6 +60,8 @@ pub struct BubbleProps { /// Which conversation this row is drawn in. A bubble does not carry it /// — the vote names the chat, so it travels in beside the message. pub chat_jid: SharedString, + /// Current message index used when text spans multiple bubbles. + pub selection_order: u64, /// Last vote requested here, not a confirmed vote. pub attempted_vote: Option, pub playing_message_id: Option, @@ -311,7 +313,12 @@ pub fn render_message_bubble( .min_w_0() .text_size(metrics.text_body()) .text_color(cx.theme().foreground) - .child(render_rich_text(content, cx)), + .child(render_rich_text( + content, + ids.selection_key.clone(), + props.selection_order, + cx, + )), ) }) .child(render_meta(time, status, is_from_me, metrics, cx)), diff --git a/crates/gui/src/components/message_list.rs b/crates/gui/src/components/message_list.rs index dd1a5fd0..b7111d9f 100644 --- a/crates/gui/src/components/message_list.rs +++ b/crates/gui/src/components/message_list.rs @@ -101,6 +101,12 @@ pub fn render_message_list( .min_h_0() .relative() .overflow_hidden() + .child( + div() + .absolute() + .size_0() + .child(crate::components::rich_text_selection::RetainedSelectionKeepalive), + ) .child( // The gutter is each row's, not the list's. `gpui::list` honours // the vertical half of its own padding and lays every row out at @@ -229,6 +235,7 @@ fn render_row( text: text.clone(), message: Arc::clone(msg), chat_jid: chat_jid.clone(), + selection_order: *ix as u64, attempted_vote: app.attempted_poll_vote(chat_jid, message_id), playing_message_id: app.playing_message_id().map(|s| s.to_string()), is_group, diff --git a/crates/gui/src/components/mod.rs b/crates/gui/src/components/mod.rs index e58501a1..86d86c87 100644 --- a/crates/gui/src/components/mod.rs +++ b/crates/gui/src/components/mod.rs @@ -18,6 +18,7 @@ pub mod parts; mod paste_preview; pub mod plugin_ui; mod rich_text; +pub(crate) mod rich_text_selection; mod status; mod status_ticks; diff --git a/crates/gui/src/components/rich_text.rs b/crates/gui/src/components/rich_text.rs index 0b24a7e7..5bc5226e 100644 --- a/crates/gui/src/components/rich_text.rs +++ b/crates/gui/src/components/rich_text.rs @@ -7,19 +7,20 @@ //! bubble — and this is the one place that turns the parsed spans into //! something GPUI paints. //! -//! Ordinary text takes the cheap path: no markers means no spans, and a plain -//! string goes straight into a `div` with no highlight vector built and no -//! second string allocated. +//! Ordinary text takes the cheap path: no markers means no spans or highlight +//! vector, and its shared string is drawn directly while still participating +//! in GPUI's window-scoped text selection. use std::ops::Range; use std::sync::Arc; use gpui::{ - App, FontStyle, FontWeight, HighlightStyle, InteractiveText, IntoElement, SharedString, - StrikethroughStyle, StyledText, UnderlineStyle, + App, FontStyle, FontWeight, HighlightStyle, IntoElement, SharedString, StrikethroughStyle, + StyledText, UnderlineStyle, }; use gpui_component::ActiveTheme as _; +use crate::components::rich_text_selection::SelectableRichText; use crate::theme::ActiveProductTheme as _; use oxidezap_core::{Emphasis, LinkSpan, find_links_in, parse_rich_text}; @@ -95,6 +96,11 @@ impl BubbleText { self.text.is_empty() } + /// The parsed text shown in the bubble, with formatting markers removed. + pub fn text(&self) -> &str { + &self.text + } + /// What each link opens, in link order. The message menu lists these, so /// every address has an equivalent activation route beside the inline /// pointer one. @@ -107,14 +113,28 @@ impl BubbleText { /// /// Returns an element either way: the caller styles size and colour on the /// parent, and both paths inherit it. -pub fn render_rich_text(parsed: &BubbleText, cx: &App) -> gpui::AnyElement { +pub fn render_rich_text( + parsed: &BubbleText, + selection_key: Arc, + document_order: u64, + cx: &App, +) -> impl IntoElement + use<> { if !parsed.links.is_empty() { - return render_with_links(parsed, cx).into_any_element(); + return render_with_links(parsed, selection_key, document_order, cx).into_any_element(); } if parsed.runs.is_empty() { - // Nothing to say about any range, so say nothing: `StyledText` with an - // empty highlight list still walks and allocates runs. - return parsed.text.clone().into_any_element(); + // Keep the cheap plain-text layout while registering the same visible + // text with GPUI's window selection model. + return SelectableRichText::new( + "message-text", + parsed.text.clone(), + StyledText::new(parsed.text.clone()), + Vec::new(), + Arc::default(), + selection_key, + document_order, + ) + .into_any_element(); } let runs = &parsed.runs; @@ -134,10 +154,18 @@ pub fn render_rich_text(parsed: &BubbleText, cx: &App) -> gpui::AnyElement { .map(|(range, emphasis)| (range.clone(), style_for(*emphasis, metrics))) .collect(); - StyledText::new(text) - .with_highlights(highlights) - .with_font_family_overrides(code) - .into_any_element() + SelectableRichText::new( + "message-text", + text.clone(), + StyledText::new(text) + .with_highlights(highlights) + .with_font_family_overrides(code), + Vec::new(), + Arc::default(), + selection_key, + document_order, + ) + .into_any_element() } /// One run's appearance. @@ -162,15 +190,20 @@ fn style_for(emphasis: Emphasis, metrics: crate::theme::Metrics) -> HighlightSty /// Message text that holds links, in one inline flow. /// /// A `StyledText` paints but answers no clicks, so the addresses ride along -/// as clickable ranges on an `InteractiveText` instead of becoming elements -/// of their own. Nothing is split into flex children, so a newline before an -/// address starts a line the way it does without one, and a long address +/// as clickable ranges on the selection-aware inline element instead of +/// becoming elements of their own. Nothing is split into flex children, so a +/// newline before an address starts a line the way it does without one, and a long address /// wraps the way plain text does rather than overflowing its item into the /// bubble's `overflow_hidden`. Clicks open the target through `cx.open_url`, /// which is why this needs no platform split of its own: GPUI answers that /// on the desktop and in the page alike. Size and colour are inherited from /// the parent; only the link ink comes from the theme. -fn render_with_links(parsed: &BubbleText, cx: &App) -> impl IntoElement + use<> { +fn render_with_links( + parsed: &BubbleText, + selection_key: Arc, + document_order: u64, + cx: &App, +) -> impl IntoElement + use<> { let metrics = cx.product().metrics; let ink = cx.theme().link; let mono = cx.theme().mono_font_family.clone(); @@ -228,17 +261,24 @@ fn render_with_links(parsed: &BubbleText, cx: &App) -> impl IntoElement + use<> code.push((start..end, mono.clone())); } } - let styled = StyledText::new(text) + let styled = StyledText::new(text.clone()) .with_highlights(highlights) .with_font_family_overrides(code); let ranges: Vec> = parsed.links.iter().map(|link| link.range.clone()).collect(); // A refcount per frame, not a copy per target: the strings were shared // when the bubble was parsed. let targets = parsed.link_targets.clone(); - // One instance per bubble, scoped under the row's own id. - InteractiveText::new("message-links", styled).on_click(ranges, move |ix, _window, cx| { - cx.open_url(&targets[ix]); - }) + // The single formatted layout remains intact for wrapping and selection; + // the adapter dispatches a click only when the pointer did not drag. + SelectableRichText::new( + "message-text", + text, + styled, + ranges, + targets, + selection_key, + document_order, + ) } /// One run's appearance inside a link: its own emphasis, inked and underlined @@ -262,7 +302,17 @@ fn link_style( #[cfg(test)] mod tests { - use super::BubbleText; + use std::sync::Arc; + + use gpui::{ + AppContext as _, Context, FocusHandle, InteractiveElement as _, IntoElement, Modifiers, + MouseButton, ParentElement as _, Render, Styled as _, TestAppContext, Window, div, point, + px, + }; + + use gpui::prelude::FluentBuilder as _; + + use super::{BubbleText, render_rich_text}; /// Links are resolved where the rows are built, beside the markup — so a /// frame hands out ranges rather than scanning the peer's text again. @@ -426,6 +476,314 @@ mod tests { assert_eq!(targets[1].as_str(), parsed.links[1].target.as_str()); } + struct TextSelectionTestView { + source: &'static str, + selection_key: Arc, + focus_handle: FocusHandle, + show_text: bool, + } + + impl Render for TextSelectionTestView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let text = BubbleText::of(self.source); + div() + .track_focus(&self.focus_handle) + .size_full() + .child( + div() + .absolute() + .size_0() + .child(crate::components::rich_text_selection::RetainedSelectionKeepalive), + ) + .when(self.show_text, |el| { + el.child( + div() + .id("message-row") + .w(px(400.)) + .h(px(40.)) + .child(render_rich_text(&text, self.selection_key.clone(), 0, cx)), + ) + }) + } + } + + fn drag_text( + source: &'static str, + start_x: f32, + end_x: f32, + cx: &mut TestAppContext, + ) -> (String, Option, Option) { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + let selected = { + let mut focus_handle = None; + let (_, visual) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| { + let handle = cx.focus_handle(); + focus_handle = Some(handle.clone()); + TextSelectionTestView { + source, + selection_key: Arc::from("test-message"), + focus_handle: handle, + show_text: true, + } + }); + gpui_component::Root::new(view, window, cx) + }); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + focus_handle.as_ref().unwrap().focus(window, cx); + }); + visual.simulate_mouse_down( + point(px(start_x), px(12.)), + MouseButton::Left, + Modifiers::default(), + ); + visual.simulate_mouse_move( + point(px(end_x), px(12.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + visual.simulate_mouse_up( + point(px(end_x), px(12.)), + MouseButton::Left, + Modifiers::default(), + ); + let selected = visual.update(|window, cx| { + window.draw(cx).clear(cx); + gpui_base::TextSelection::selected_text(window, cx) + }); + visual.dispatch_action(gpui_component::input::Copy); + selected + }; + (selected, cx.read_from_clipboard(), cx.opened_url()) + } + + fn click_text(source: &'static str, x: f32, cx: &mut TestAppContext) -> Option { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + { + let mut focus_handle = None; + let (_, visual) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| { + let handle = cx.focus_handle(); + focus_handle = Some(handle.clone()); + TextSelectionTestView { + source, + selection_key: Arc::from("test-message"), + focus_handle: handle, + show_text: true, + } + }); + gpui_component::Root::new(view, window, cx) + }); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + focus_handle.as_ref().unwrap().focus(window, cx); + }); + visual.simulate_mouse_down( + point(px(x), px(12.)), + MouseButton::Left, + Modifiers::default(), + ); + visual.simulate_mouse_move( + point(px(x + 1.), px(12.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + // The press requests a repaint; link activation must survive the + // fresh element built before release and tolerate one-pixel jitter. + visual.update(|window, cx| window.draw(cx).clear(cx)); + visual.simulate_mouse_up( + point(px(x), px(12.)), + MouseButton::Left, + Modifiers::default(), + ); + } + cx.opened_url() + } + + fn drag_link_away_and_back(cx: &mut TestAppContext) -> Option { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + { + let mut focus_handle = None; + let (_, visual) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| { + let handle = cx.focus_handle(); + focus_handle = Some(handle.clone()); + TextSelectionTestView { + source: "alpha https://example.invalid", + selection_key: Arc::from("test-message"), + focus_handle: handle, + show_text: true, + } + }); + gpui_component::Root::new(view, window, cx) + }); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + focus_handle.as_ref().unwrap().focus(window, cx); + }); + visual.simulate_mouse_down( + point(px(80.), px(12.)), + MouseButton::Left, + Modifiers::default(), + ); + visual.simulate_mouse_move( + point(px(105.), px(12.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + visual.simulate_mouse_move( + point(px(80.), px(12.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + visual.simulate_mouse_up( + point(px(80.), px(12.)), + MouseButton::Left, + Modifiers::default(), + ); + } + cx.opened_url() + } + + fn clipboard_text(item: Option) -> Option { + item?.entries.into_iter().find_map(|entry| match entry { + gpui::ClipboardEntry::String(text) => Some(text.text), + _ => None, + }) + } + + #[gpui::test] + fn plain_message_text_can_be_partially_selected_and_copied(cx: &mut TestAppContext) { + let (selected, clipboard, _) = drag_text("alpha beta", 1., 58., cx); + assert_eq!(selected, "alpha "); + // GPUI's root copy action trims the selection's outer whitespace. + assert_eq!(clipboard_text(clipboard).as_deref(), Some("alpha")); + } + + #[gpui::test] + fn selected_text_survives_a_virtualized_row_remount(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + let mut focus_handle = None; + let mut selection_view = None; + let (_, visual) = cx.add_window_view(|window, cx| { + let view = cx.new(|cx| { + let handle = cx.focus_handle(); + focus_handle = Some(handle.clone()); + TextSelectionTestView { + source: "alpha beta", + selection_key: Arc::from("test-message"), + focus_handle: handle, + show_text: true, + } + }); + selection_view = Some(view.clone()); + gpui_component::Root::new(view, window, cx) + }); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + focus_handle.as_ref().unwrap().focus(window, cx); + }); + visual.simulate_mouse_down( + point(px(1.), px(12.)), + MouseButton::Left, + Modifiers::default(), + ); + visual.simulate_mouse_move( + point(px(58.), px(12.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + visual.simulate_mouse_up( + point(px(58.), px(12.)), + MouseButton::Left, + Modifiers::default(), + ); + assert_eq!( + visual.update(|window, cx| { + window.draw(cx).clear(cx); + gpui_base::TextSelection::selected_text(window, cx) + }), + "alpha " + ); + + visual.update(|window, cx| { + selection_view.as_ref().unwrap().update(cx, |view, cx| { + view.show_text = false; + cx.notify(); + }); + window.draw(cx).clear(cx); + }); + assert_eq!( + visual.update(|window, cx| { + window.draw(cx).clear(cx); + gpui_base::TextSelection::selected_text(window, cx) + }), + "alpha " + ); + visual.update(|window, cx| { + selection_view.as_ref().unwrap().update(cx, |view, cx| { + view.show_text = true; + cx.notify(); + }); + window.draw(cx).clear(cx); + }); + assert_eq!( + visual.update(|window, cx| { + window.draw(cx).clear(cx); + gpui_base::TextSelection::selected_text(window, cx) + }), + "alpha " + ); + } + + #[gpui::test] + fn formatted_message_selection_copies_visible_text_without_markers(cx: &mut TestAppContext) { + let (selected, clipboard, _) = drag_text("*alpha* beta", 1., 58., cx); + assert_eq!(selected, "alpha "); + assert_eq!(clipboard_text(clipboard).as_deref(), Some("alpha")); + } + + #[gpui::test] + fn message_selection_preserves_unicode_boundaries(cx: &mut TestAppContext) { + let source = "café 😀 e\u{301}"; + let (selected, clipboard, _) = drag_text(source, 1., 380., cx); + assert_eq!(selected, source); + assert_eq!(clipboard_text(clipboard).as_deref(), Some(source)); + } + + #[gpui::test] + fn dragging_over_a_link_selects_text_instead_of_opening_it(cx: &mut TestAppContext) { + let (selected, _, opened_url) = drag_text("alpha https://example.invalid", 1., 90., cx); + assert!(!selected.is_empty()); + assert_eq!(opened_url, None); + } + + #[gpui::test] + fn clicking_a_message_link_still_opens_it(cx: &mut TestAppContext) { + assert_eq!( + click_text("alpha https://example.invalid", 80., cx).as_deref(), + Some("https://example.invalid") + ); + } + + #[gpui::test] + fn dragging_a_link_away_and_back_does_not_open_it(cx: &mut TestAppContext) { + assert_eq!(drag_link_away_and_back(cx), None); + } + /// A stopwatch rather than an assertion: what a conversation pays to /// re-derive text nothing changed, and what it pays now that it does not. /// diff --git a/crates/gui/src/components/rich_text_selection.rs b/crates/gui/src/components/rich_text_selection.rs new file mode 100644 index 00000000..9ce5807a --- /dev/null +++ b/crates/gui/src/components/rich_text_selection.rs @@ -0,0 +1,1014 @@ +//! GPUI's selectable-text bridge for formatted message bubbles. +//! +//! `SelectableText` handles a plain string, but it cannot carry WhatsApp's +//! inline styles or clickable link ranges. This element keeps one `StyledText` +//! layout and registers that same visible text with GPUI Kit's existing +//! window selection model. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::ops::Range; +use std::rc::Rc; +use std::sync::Arc; + +use gpui::{ + App, BorderStyle, Bounds, Corners, CursorStyle, Edges, Element, ElementId, Global, + GlobalElementId, Half, Hitbox, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, + SharedString, Style, StyledText, Window, transparent_black, +}; +use gpui_base::{ + TextSelection, TextSelectionHandle, TextSelectionProjection, TextSelectionRegistration, + TextSelectionRun, +}; +use gpui_component::ActiveTheme as _; + +struct RetainedSelection { + handle: TextSelectionHandle, + text: SharedString, + pressed_link: Rc>>, + projection: Rc>, +} + +struct RetainedParticipant { + handle: TextSelectionHandle, + text: SharedString, + document_order: u64, + last_bounds: Option>, + _selection_subscription: gpui::Subscription, +} + +pub(super) struct RichTextState { + handle: TextSelectionHandle, + pressed_link: Rc>>, + projection: Rc>, +} + +#[derive(Clone, Copy)] +struct LinkPress { + link_ix: usize, + origin: Point, + dragged: bool, +} + +/// Holds participants by message identity until their selection ends. +#[derive(Default)] +struct RichTextSelectionRegistry(HashMap<(gpui::WindowId, Arc), RetainedParticipant>); + +impl Global for RichTextSelectionRegistry {} + +fn new_retained_selection(text: &SharedString, cx: &mut App) -> RetainedSelection { + retained_selection(TextSelectionHandle::new(text.to_string(), cx), text) +} + +fn retained_selection(handle: TextSelectionHandle, text: &SharedString) -> RetainedSelection { + RetainedSelection { + handle, + text: text.clone(), + pressed_link: Rc::new(Cell::new(None)), + projection: Rc::new(RefCell::new(TextSelectionProjection::default())), + } +} + +/// Active message identities and their retained text and timeline order. +pub(crate) fn active_selection_message_snapshots( + window_id: gpui::WindowId, + cx: &App, +) -> Vec<(String, SharedString, u64)> { + if !cx.has_global::() { + return Vec::new(); + } + cx.global::() + .0 + .iter() + .filter(|((id, _), participant)| { + *id == window_id && participant.handle.snapshot(cx).is_some() + }) + .map(|((_, message_id), participant)| { + ( + message_id.to_string(), + participant.text.clone(), + participant.document_order, + ) + }) + .collect() +} + +fn record_selection_bounds( + window_id: gpui::WindowId, + message_id: &Arc, + bounds: Bounds, + cx: &mut App, +) { + if cx.has_global::() + && let Some(participant) = cx + .global_mut::() + .0 + .get_mut(&(window_id, Arc::clone(message_id))) + { + participant.last_bounds = Some(bounds); + } +} + +fn track_selection_handle( + window_id: gpui::WindowId, + message_id: &Arc, + handle: &TextSelectionHandle, + text: &SharedString, + document_order: u64, + cx: &mut App, +) { + let key = (window_id, Arc::clone(message_id)); + if handle.snapshot(cx).is_some() { + if !cx.has_global::() { + cx.set_global(RichTextSelectionRegistry::default()); + } + let already_retained = cx + .global::() + .0 + .get(&key) + .is_some_and(|participant| { + participant.handle.entity_id() == handle.entity_id() && participant.text == *text + }); + if already_retained { + if let Some(participant) = cx.global_mut::().0.get_mut(&key) + { + participant.document_order = document_order; + } + } else { + let registry_key = key.clone(); + let subscription = handle.subscribe( + move |event, cx| { + if matches!( + event, + gpui_base::TextSelectionEvent::Cleared + | gpui_base::TextSelectionEvent::SelectionChanged(None) + ) { + let registry_key = registry_key.clone(); + cx.defer(move |cx| { + let inactive = cx.has_global::() + && cx + .global::() + .0 + .get(®istry_key) + .is_some_and(|participant| { + participant.handle.snapshot(cx).is_none() + }); + if inactive { + cx.global_mut::() + .0 + .remove(®istry_key); + } + }); + } + }, + cx, + ); + cx.global_mut::().0.insert( + key, + RetainedParticipant { + handle: handle.clone(), + text: text.clone(), + document_order, + last_bounds: None, + _selection_subscription: subscription, + }, + ); + } + } else if cx.has_global::() { + cx.global_mut::().0.remove(&key); + } +} + +/// Releases retained participant handles after a window-level clear. +pub(crate) fn forget_window_selection_registry(window_id: gpui::WindowId, cx: &mut App) { + if cx.has_global::() { + cx.global_mut::() + .0 + .retain(|(id, _), _| *id != window_id); + } +} + +/// Keeps selected virtualized rows registered with GPUI's per-frame selection sweep. +pub(crate) struct RetainedSelectionKeepalive; + +impl IntoElement for RetainedSelectionKeepalive { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for RetainedSelectionKeepalive { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let window_id = window.window_handle().window_id(); + let retained = if cx.has_global::() { + cx.global::() + .0 + .iter() + .filter(|((id, _), participant)| { + *id == window_id && participant.handle.snapshot(cx).is_some() + }) + .map(|(_, participant)| { + ( + participant.handle.clone(), + participant.document_order, + participant.last_bounds, + ) + }) + .filter_map(|(handle, document_order, bounds)| { + bounds.map(|bounds| (handle, document_order, bounds)) + }) + .collect::>() + } else { + Vec::new() + }; + if retained.is_empty() { + return; + } + + // Keep the selection participants alive without presenting stale row + // geometry as a hit target. The hitbox still inherits the timeline's + // clipping mask, which is what GPUI uses for drag auto-scroll. + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + let dormant_y = bounds.bottom() + window.viewport_size().height + window.rem_size(); + for (handle, document_order, last_bounds) in retained { + let dormant_bounds = Bounds::new( + Point::new(last_bounds.left(), dormant_y), + gpui::Size::default(), + ); + let scroll_offset = Point::new( + last_bounds.origin.x - dormant_bounds.origin.x, + last_bounds.origin.y - dormant_bounds.origin.y, + ); + handle.register( + TextSelectionRegistration::new(hitbox.clone(), dormant_bounds) + .with_scroll_offset(scroll_offset) + .with_document_order(document_order) + .with_text_bounds(vec![last_bounds]), + window, + cx, + ); + } + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + _: &mut Window, + _: &mut App, + ) { + } +} + +/// One formatted message text run participating in GPUI's window selection. +pub(super) struct SelectableRichText { + id: ElementId, + text: SharedString, + styled_text: StyledText, + links: Vec>, + link_targets: Arc<[SharedString]>, + selection_key: Arc, + document_order: u64, +} + +impl SelectableRichText { + pub(super) fn new( + id: impl Into, + text: SharedString, + styled_text: StyledText, + links: Vec>, + link_targets: Arc<[SharedString]>, + selection_key: Arc, + document_order: u64, + ) -> Self { + Self { + id: id.into(), + text, + styled_text, + links, + link_targets, + selection_key, + document_order, + } + } + + fn paint_selection( + layout: &gpui::TextLayout, + text: &str, + range: Range, + color: gpui::Hsla, + window: &mut Window, + ) { + for bounds in selection_quad_bounds(text, range, layout) { + window.paint_quad(PaintQuad { + bounds, + background: color.into(), + corner_radii: Corners::default(), + border_widths: Edges::default(), + border_color: transparent_black(), + border_style: BorderStyle::default(), + }); + } + } +} + +fn selection_quad_bounds( + text: &str, + range: Range, + layout: &gpui::TextLayout, +) -> Vec> { + let bounds = layout.bounds(); + let line_height = layout.line_height(); + let mut fragments = Vec::new(); + let mut line_start_ix = 0; + let mut line_top = bounds.top(); + + for line in layout.line_layouts() { + let runs = line.runs(); + let wrap_boundaries = line.wrap_boundaries(); + let mut source_boundaries: Vec = runs + .iter() + .flat_map(|run| run.glyphs.iter().map(|glyph| line_start_ix + glyph.index)) + .collect(); + source_boundaries.sort_unstable(); + source_boundaries.dedup(); + let cluster_ends: HashMap = source_boundaries + .windows(2) + .map(|pair| (pair[0], pair[1])) + .collect(); + // Preserve caret positions and all glyph extrema per source boundary. + let mut shaped_x_by_index = HashMap::new(); + let mut glyph_x_extents: HashMap = HashMap::new(); + for run in runs { + for glyph in &run.glyphs { + let source_ix = line_start_ix + glyph.index; + shaped_x_by_index + .entry(source_ix) + .or_insert(glyph.position.x); + glyph_x_extents + .entry(source_ix) + .and_modify(|(left, right)| { + if glyph.position.x < *left { + *left = glyph.position.x; + } + if glyph.position.x > *right { + *right = glyph.position.x; + } + }) + .or_insert((glyph.position.x, glyph.position.x)); + } + } + let mut segments = vec![Vec::new(); wrap_boundaries.len() + 1]; + let mut segment_ix = 0; + let mut next_boundary_ix = 0; + for (run_ix, run) in runs.iter().enumerate() { + for (glyph_ix, glyph) in run.glyphs.iter().enumerate() { + if wrap_boundaries + .get(next_boundary_ix) + .is_some_and(|boundary| { + boundary.run_ix == run_ix && boundary.glyph_ix == glyph_ix + }) + { + segment_ix += 1; + next_boundary_ix += 1; + } + segments[segment_ix].push(( + line_start_ix + glyph.index, + glyph.position.x, + run.font_id, + )); + } + } + + for (segment_ix, glyphs) in segments.iter().enumerate() { + let Some((_, first_x, _)) = glyphs.first() else { + continue; + }; + let mut visual_left = *first_x; + let mut visual_right = *first_x; + for (_, x, _) in glyphs.iter().skip(1) { + if *x < visual_left { + visual_left = *x; + } + if *x > visual_right { + visual_right = *x; + } + } + + let segment_top = line_top + line_height * segment_ix as f32; + let trailing_source_ix = glyphs + .iter() + .filter(|(_, x, _)| *x == visual_right) + .map(|(source_ix, _, _)| *source_ix) + .max(); + let trailing_advance = trailing_source_ix + .and_then(|source_ix| { + let start_x = shaped_x_by_index.get(&source_ix)?; + let cluster_end_ix = cluster_ends + .get(&source_ix) + .copied() + .unwrap_or(line_start_ix + line.len()); + let end_x = shaped_x_by_index + .get(&cluster_end_ix) + .copied() + .unwrap_or(line.unwrapped_layout.width); + let (glyph_left, glyph_right) = glyph_x_extents.get(&source_ix)?; + Some(trailing_cluster_extension( + *start_x, + end_x, + *glyph_left, + *glyph_right, + )) + }) + .unwrap_or(line_height.half()); + let visual_glyphs: Vec<_> = glyphs + .iter() + .map(|(source_ix, x, _)| { + ( + *source_ix, + *x - visual_left, + if *x == visual_right && Some(*source_ix) == trailing_source_ix { + trailing_advance + } else { + Pixels::ZERO + }, + ) + }) + .collect(); + + let segment_width = visual_right - visual_left + trailing_advance; + if segment_width <= Pixels::ZERO { + continue; + } + let segment_bounds = Bounds::from_corners( + Point::new(bounds.left(), segment_top), + Point::new(bounds.left() + segment_width, segment_top + line_height), + ); + fragments.extend(selection_glyph_bounds( + &visual_glyphs, + &range, + segment_bounds, + )); + } + + line_top += line.size(line_height).height; + line_start_ix += line.len() + 1; + } + + for (offset, character) in text[range.clone()].char_indices() { + if character != '\n' { + continue; + } + let start_ix = range.start + offset; + let Some(start) = layout.position_for_index(start_ix) else { + continue; + }; + let line_start_ix = text[..start_ix] + .rfind('\n') + .map_or(0, |previous_newline| previous_newline + 1); + let Some(line_start) = layout.position_for_index(line_start_ix) else { + continue; + }; + if let Some(fragment) = hard_break_selection_bounds(line_start, start, bounds, line_height) + { + fragments.push(fragment); + } + } + + merge_selection_fragments(fragments) +} + +fn selection_glyph_bounds( + glyphs: &[(usize, Pixels, Pixels)], + range: &Range, + segment_bounds: Bounds, +) -> Vec> { + let mut visual_glyphs = glyphs.to_vec(); + visual_glyphs.sort_by(|left, right| { + left.1 + .partial_cmp(&right.1) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut right_edges = vec![Pixels::ZERO; visual_glyphs.len()]; + let mut group_start = 0; + while group_start < visual_glyphs.len() { + let x = visual_glyphs[group_start].1; + let mut group_end = group_start + 1; + let mut group_advance = visual_glyphs[group_start].2; + while group_end < visual_glyphs.len() && visual_glyphs[group_end].1 == x { + if visual_glyphs[group_end].2 > group_advance { + group_advance = visual_glyphs[group_end].2; + } + group_end += 1; + } + let right = if group_end < visual_glyphs.len() { + visual_glyphs[group_end].1 + } else { + x + group_advance + }; + right_edges[group_start..group_end].fill(right); + group_start = group_end; + } + + let mut fragments = Vec::new(); + for ((source_ix, x, _), right) in visual_glyphs.iter().zip(right_edges) { + if range.contains(source_ix) && right > *x { + fragments.push(Bounds::from_corners( + Point::new(segment_bounds.left() + *x, segment_bounds.top()), + Point::new(segment_bounds.left() + right, segment_bounds.bottom()), + )); + } + } + fragments +} + +fn trailing_cluster_extension( + source_start: Pixels, + source_end: Pixels, + glyph_left: Pixels, + glyph_right: Pixels, +) -> Pixels { + let shaped_advance = (source_end - source_start).abs(); + let positioned_span = glyph_right - glyph_left; + if shaped_advance > positioned_span { + shaped_advance - positioned_span + } else { + Pixels::ZERO + } +} + +fn link_at_position( + layout: &gpui::TextLayout, + text: &str, + links: &[Range], + position: Point, +) -> Option { + let index = match layout.index_for_position(position) { + Ok(index) | Err(index) => index, + }; + link_index_at_position(index, links, |range| { + selection_quad_bounds(text, range.clone(), layout) + .iter() + .any(|bounds| { + position.x >= bounds.left() + && position.x <= bounds.right() + && position.y >= bounds.top() + && position.y <= bounds.bottom() + }) + }) +} + +fn link_index_at_position( + index: usize, + links: &[Range], + end_range_contains_pointer: impl Fn(&Range) -> bool, +) -> Option { + if let Some(link_ix) = links.iter().position(|range| { + range.end == index && !range.is_empty() && end_range_contains_pointer(range) + }) { + return Some(link_ix); + } + links.iter().position(|range| range.contains(&index)) +} + +fn hard_break_selection_bounds( + line_start: Point, + line_end: Point, + bounds: Bounds, + line_height: Pixels, +) -> Option> { + let trailing_edge = if line_end.x >= line_start.x { + bounds.right() + } else { + bounds.left() + }; + let (left, right) = if line_end.x <= trailing_edge { + (line_end.x, trailing_edge) + } else { + (trailing_edge, line_end.x) + }; + (left < right).then(|| { + Bounds::from_corners( + Point::new(left, line_end.y), + Point::new(right, line_end.y + line_height), + ) + }) +} + +fn merge_selection_fragments(mut fragments: Vec>) -> Vec> { + fragments.sort_by(|left, right| { + left.origin + .y + .partial_cmp(&right.origin.y) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + left.origin + .x + .partial_cmp(&right.origin.x) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }); + let mut merged: Vec> = Vec::with_capacity(fragments.len()); + for fragment in fragments { + if let Some(previous) = merged.last_mut() + && previous.origin.y == fragment.origin.y + && fragment.origin.x <= previous.right() + { + if fragment.right() > previous.right() { + previous.size.width = fragment.right() - previous.origin.x; + } + } else { + merged.push(fragment); + } + } + merged +} + +impl IntoElement for SelectableRichText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for SelectableRichText { + type RequestLayoutState = RichTextState; + type PrepaintState = Hitbox; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let window_id = window.window_handle().window_id(); + let registry_key = (window_id, Arc::clone(&self.selection_key)); + let registry_participant = cx + .has_global::() + .then(|| { + cx.global::() + .0 + .get(®istry_key) + .map(|participant| (participant.handle.clone(), participant.text.clone())) + }) + .flatten(); + let registry_handle = match registry_participant { + Some((handle, text)) if text == self.text && handle.snapshot(cx).is_some() => { + Some(handle) + } + Some((handle, text)) if text != self.text && handle.snapshot(cx).is_some() => { + // The bubble changed while virtualized, so its retained + // participant must not keep exporting the previous text. + TextSelection::clear(window, cx); + forget_window_selection_registry(window_id, cx); + None + } + _ => None, + }; + let handle = window.with_element_state( + global_id.expect("SelectableRichText must have a stable element id"), + |retained: Option, window| { + let retained = match retained { + Some(retained) if retained.text == self.text => retained, + Some(retained) => { + // Only this participant's selection became stale; + // edits to another bubble must not clear it. + if retained.handle.snapshot(cx).is_some() { + TextSelection::clear(window, cx); + forget_window_selection_registry(window_id, cx); + } + new_retained_selection(&self.text, cx) + } + None => match registry_handle.clone() { + Some(handle) => retained_selection(handle, &self.text), + None => new_retained_selection(&self.text, cx), + }, + }; + let state = RichTextState { + handle: retained.handle.clone(), + pressed_link: Rc::clone(&retained.pressed_link), + projection: Rc::clone(&retained.projection), + }; + (state, retained) + }, + ); + track_selection_handle( + window_id, + &self.selection_key, + &handle.handle, + &self.text, + self.document_order, + cx, + ); + let (layout_id, ()) = self + .styled_text + .request_layout(global_id, inspector_id, window, cx); + (layout_id, handle) + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + handle: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + self.styled_text + .prepaint(global_id, inspector_id, bounds, &mut (), window, cx); + let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); + handle.handle.register( + TextSelectionRegistration::new(hitbox.clone(), bounds) + .with_document_order(self.document_order) + .with_text_bounds(vec![bounds]), + window, + cx, + ); + record_selection_bounds( + window.window_handle().window_id(), + &self.selection_key, + bounds, + cx, + ); + hitbox + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + handle: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let layout = self.styled_text.layout().clone(); + let projection = handle.handle.update_runs( + &[ + TextSelectionRun::new(self.text.clone(), layout.clone(), bounds) + .with_document_order(self.document_order), + ], + cx, + ); + { + let mut previous_projection = handle.projection.borrow_mut(); + if *previous_projection != projection { + *previous_projection = projection.clone(); + window.refresh(); + } + } + let selection_color = cx.theme().selection; + for range in projection.ranges().iter().flatten().cloned() { + Self::paint_selection(&layout, &self.text, range, selection_color, window); + } + self.styled_text.paint( + global_id, + inspector_id, + bounds, + &mut (), + &mut (), + window, + cx, + ); + + if self.links.is_empty() { + return; + } + let links = self.links.clone(); + let mouse_position = window.mouse_position(); + if link_at_position(&layout, &self.text, &links, mouse_position).is_some() { + window.set_cursor_style(CursorStyle::PointingHand, hitbox); + } + let targets = self.link_targets.clone(); + let link_text = self.text.clone(); + let down_state = Rc::clone(&handle.pressed_link); + let mouse_down_state = Rc::clone(&down_state); + let down_layout = layout.clone(); + let down_hitbox = hitbox.clone(); + let down_links = links.clone(); + let down_text = link_text.clone(); + window.on_mouse_event(move |event: &MouseDownEvent, phase, window, _cx| { + if phase.bubble() && event.button == MouseButton::Left { + let press = if down_hitbox.is_hovered(window) { + link_at_position(&down_layout, &down_text, &down_links, event.position).map( + |link_ix| LinkPress { + link_ix, + origin: event.position, + dragged: false, + }, + ) + } else { + None + }; + mouse_down_state.set(press); + if press.is_some() { + window.refresh(); + } + } + }); + let move_state = Rc::clone(&down_state); + let drag_slop = window.rem_size() * 0.2; + window.on_mouse_event(move |event: &MouseMoveEvent, phase, _window, _cx| { + if !phase.bubble() || event.pressed_button != Some(MouseButton::Left) { + return; + } + if let Some(mut press) = move_state.get() + && ((event.position.x - press.origin.x).abs() > drag_slop + || (event.position.y - press.origin.y).abs() > drag_slop) + { + press.dragged = true; + move_state.set(Some(press)); + } + }); + let up_layout = layout; + let up_text = link_text; + let up_hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| { + if !phase.bubble() || event.button != MouseButton::Left { + return; + } + let Some(press) = down_state.replace(None) else { + return; + }; + if press.dragged || !up_hitbox.is_hovered(window) { + return; + } + let Some(link_ix) = link_at_position(&up_layout, &up_text, &links, event.position) + else { + return; + }; + if link_ix != press.link_ix { + return; + } + if TextSelection::has_selection(window, cx) { + // A click-sized pointer wiggle can create a transient local + // selection. It should not suppress link activation or leave + // a tiny selection behind after the click. + TextSelection::clear(window, cx); + } + TextSelection::end(window, cx); + cx.stop_propagation(); + cx.open_url(&targets[link_ix]); + }); + } +} + +#[cfg(test)] +mod tests { + use gpui::{Bounds, point, px}; + + use super::{ + hard_break_selection_bounds, link_index_at_position, merge_selection_fragments, + selection_glyph_bounds, trailing_cluster_extension, + }; + + #[test] + fn bidi_selection_fragments_merge_in_visual_order_without_bridging_runs() { + let fragment = |left, top, right, bottom| { + Bounds::from_corners(point(px(left), px(top)), point(px(right), px(bottom))) + }; + let quads = merge_selection_fragments(vec![ + fragment(80., 20., 100., 40.), + fragment(30., 20., 45., 40.), + fragment(10., 20., 30., 40.), + fragment(10., 40., 30., 60.), + ]); + + assert_eq!( + quads, + vec![ + fragment(10., 20., 45., 40.), + fragment(80., 20., 100., 40.), + fragment(10., 40., 30., 60.), + ] + ); + } + + #[test] + fn mixed_direction_selection_uses_glyph_clusters_at_run_boundaries() { + let glyphs = [ + (0, px(0.), px(0.)), + (1, px(10.), px(0.)), + (2, px(20.), px(0.)), + (3, px(30.), px(0.)), + (4, px(60.), px(10.)), + (6, px(50.), px(0.)), + (8, px(40.), px(0.)), + ]; + let line = Bounds::from_corners(point(px(0.), px(20.)), point(px(70.), px(40.))); + let fragments = merge_selection_fragments(selection_glyph_bounds(&glyphs, &(3..6), line)); + + assert_eq!( + fragments, + vec![ + Bounds::from_corners(point(px(30.), px(20.)), point(px(40.), px(40.))), + Bounds::from_corners(point(px(60.), px(20.)), point(px(70.), px(40.))), + ] + ); + } + + #[test] + fn trailing_cluster_extension_keeps_all_positioned_glyphs() { + assert_eq!( + trailing_cluster_extension(px(0.), px(15.), px(0.), px(10.)), + px(5.) + ); + assert_eq!( + trailing_cluster_extension(px(15.), px(5.), px(15.), px(15.)), + px(10.) + ); + } + + #[test] + fn exclusive_link_end_requires_a_hit_on_the_final_glyph() { + let links = [0..4, 4..8]; + assert_eq!(link_index_at_position(4, &links, |_| false), Some(1)); + assert_eq!(link_index_at_position(8, &links, |_| false), None); + assert_eq!(link_index_at_position(8, &links, |_| true), Some(1)); + } + + #[test] + fn short_ltr_newline_highlight_extends_to_the_trailing_edge() { + let bounds = Bounds::from_corners(point(px(0.), px(0.)), point(px(100.), px(20.))); + assert_eq!( + hard_break_selection_bounds( + point(px(0.), px(0.)), + point(px(10.), px(0.)), + bounds, + px(20.) + ), + Some(Bounds::from_corners( + point(px(10.), px(0.)), + point(px(100.), px(20.)), + )) + ); + } + + #[test] + fn equal_position_glyphs_share_a_precomputed_visual_edge() { + let glyphs = [ + (3, px(0.), px(0.)), + (4, px(0.), px(10.)), + (5, px(0.), px(0.)), + (6, px(10.), px(0.)), + ]; + let line = Bounds::from_corners(point(px(0.), px(20.)), point(px(20.), px(40.))); + let fragments = merge_selection_fragments(selection_glyph_bounds(&glyphs, &(3..6), line)); + + assert_eq!( + fragments, + vec![Bounds::from_corners( + point(px(0.), px(20.)), + point(px(10.), px(40.)), + )] + ); + } +} diff --git a/crates/gui/src/views/chat.rs b/crates/gui/src/views/chat.rs index eeaa22ba..b3f93b02 100644 --- a/crates/gui/src/views/chat.rs +++ b/crates/gui/src/views/chat.rs @@ -78,7 +78,7 @@ pub fn render_connected_view( && app.media_viewer(cx).is_none()) .then(|| selected_jid.clone()) .flatten(); - app.note_visible_conversation(visible, cx); + app.note_visible_conversation(visible, window, cx); // Against what this frame is about to draw, and before anything reads the // chat list: a chat a complete load said was gone is kept only while it is // on screen, and this is where looking away is noticed.