1use std::ops::Range;
2
3use gpui::{
4 App, BorderStyle, Bounds, Corners, Edges, Element, ElementId, GlobalElementId, Hitbox,
5 HitboxBehavior, Hsla, InspectorElementId, IntoElement, LayoutId, PaintQuad, Pixels, Point,
6 SharedString, StyledText, TextStyleRefinement, Window, transparent_black,
7};
8
9use crate::{TextSelectionHandle, TextSelectionRegistration, TextSelectionRun};
10
11pub struct SelectableText {
20 id: ElementId,
21 handle: Option<TextSelectionHandle>,
22 text: SharedString,
23 styled_text: StyledText,
24 document_order: u64,
25 text_style: Option<TextStyleRefinement>,
26 selection_color: Option<Hsla>,
27}
28
29impl SelectableText {
30 pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
32 Self::build(id.into(), None, text.into())
33 }
34
35 pub fn with_handle(
37 id: impl Into<ElementId>,
38 handle: TextSelectionHandle,
39 text: impl Into<SharedString>,
40 ) -> Self {
41 Self::build(id.into(), Some(handle), text.into())
42 }
43
44 fn build(id: ElementId, handle: Option<TextSelectionHandle>, text: SharedString) -> Self {
45 Self {
46 id,
47 handle,
48 styled_text: StyledText::new(text.clone()),
49 text,
50 document_order: 0,
51 text_style: None,
52 selection_color: None,
53 }
54 }
55
56 pub fn document_order(mut self, order: u64) -> Self {
58 self.document_order = order;
59 self
60 }
61
62 pub fn text_style(mut self, style: TextStyleRefinement) -> Self {
64 self.text_style = Some(style);
65 self
66 }
67
68 pub fn selection_color(mut self, color: Hsla) -> Self {
71 self.selection_color = Some(color);
72 self
73 }
74
75 fn paint_selection(
76 layout: &gpui::TextLayout,
77 range: Range<usize>,
78 color: Hsla,
79 window: &mut Window,
80 ) {
81 let (Some(start), Some(end)) = (
82 layout.position_for_index(range.start),
83 layout.position_for_index(range.end),
84 ) else {
85 return;
86 };
87 for bounds in selection_quad_bounds(start, end, layout.bounds(), layout.line_height()) {
88 window.paint_quad(PaintQuad {
89 bounds,
90 background: color.into(),
91 corner_radii: Corners::default(),
92 border_widths: Edges::default(),
93 border_color: transparent_black(),
94 border_style: BorderStyle::default(),
95 });
96 }
97 }
98}
99
100fn selection_quad_bounds(
101 start: Point<Pixels>,
102 end: Point<Pixels>,
103 bounds: Bounds<Pixels>,
104 line_height: Pixels,
105) -> Vec<Bounds<Pixels>> {
106 if start.y == end.y {
107 return vec![Bounds::from_corners(
108 start,
109 Point::new(end.x, end.y + line_height),
110 )];
111 }
112
113 let mut quads = vec![Bounds::from_corners(
114 start,
115 Point::new(bounds.right(), start.y + line_height),
116 )];
117 if end.y > start.y + line_height {
118 quads.push(Bounds::from_corners(
119 Point::new(bounds.left(), start.y + line_height),
120 Point::new(bounds.right(), end.y),
121 ));
122 }
123 quads.push(Bounds::from_corners(
124 Point::new(bounds.left(), end.y),
125 Point::new(end.x, end.y + line_height),
126 ));
127 quads
128}
129
130impl IntoElement for SelectableText {
131 type Element = Self;
132
133 fn into_element(self) -> Self::Element {
134 self
135 }
136}
137
138impl Element for SelectableText {
139 type RequestLayoutState = TextSelectionHandle;
140 type PrepaintState = Hitbox;
141
142 fn id(&self) -> Option<ElementId> {
143 Some(self.id.clone())
144 }
145
146 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
147 None
148 }
149
150 fn request_layout(
151 &mut self,
152 global_id: Option<&GlobalElementId>,
153 inspector_id: Option<&InspectorElementId>,
154 window: &mut Window,
155 cx: &mut App,
156 ) -> (LayoutId, Self::RequestLayoutState) {
157 let handle = self.handle.clone().unwrap_or_else(|| {
158 window.with_element_state(
159 global_id.expect("SelectableText must have a stable element id"),
160 |retained: Option<TextSelectionHandle>, _| {
161 let handle =
162 retained.unwrap_or_else(|| TextSelectionHandle::new(self.text.clone(), cx));
163 (handle.clone(), handle)
164 },
165 )
166 });
167 let (layout_id, ()) = if let Some(style) = self.text_style.clone() {
168 window.with_text_style(Some(style), |window| {
169 self.styled_text
170 .request_layout(global_id, inspector_id, window, cx)
171 })
172 } else {
173 self.styled_text
174 .request_layout(global_id, inspector_id, window, cx)
175 };
176 (layout_id, handle)
177 }
178
179 fn prepaint(
180 &mut self,
181 global_id: Option<&GlobalElementId>,
182 inspector_id: Option<&InspectorElementId>,
183 bounds: Bounds<Pixels>,
184 handle: &mut Self::RequestLayoutState,
185 window: &mut Window,
186 cx: &mut App,
187 ) -> Self::PrepaintState {
188 self.styled_text
189 .prepaint(global_id, inspector_id, bounds, &mut (), window, cx);
190 let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
191 let registration = TextSelectionRegistration::new(hitbox.clone(), bounds)
192 .with_document_order(self.document_order)
193 .with_text_bounds(vec![bounds])
194 .with_rendered_element(handle, window, cx);
195 handle.register(registration, window, cx);
196 hitbox
197 }
198
199 fn paint(
200 &mut self,
201 global_id: Option<&GlobalElementId>,
202 inspector_id: Option<&InspectorElementId>,
203 bounds: Bounds<Pixels>,
204 handle: &mut Self::RequestLayoutState,
205 _: &mut Self::PrepaintState,
206 window: &mut Window,
207 cx: &mut App,
208 ) {
209 let layout = self.styled_text.layout().clone();
210 let copy_text_before = handle.projected_copy_text(cx);
211 let projection = handle.update_runs(
212 &[
213 TextSelectionRun::new(self.text.clone(), layout.clone(), bounds)
214 .with_document_order(self.document_order),
215 ],
216 cx,
217 );
218 if copy_text_before != handle.projected_copy_text(cx) {
219 window.refresh();
220 }
221 let color = self
222 .selection_color
223 .unwrap_or_else(|| crate::Theme::global(cx).tokens.colors.selection);
224 for range in projection.ranges().iter().flatten().cloned() {
225 Self::paint_selection(&layout, range, color, window);
226 }
227 self.styled_text.paint(
228 global_id,
229 inspector_id,
230 bounds,
231 &mut (),
232 &mut (),
233 window,
234 cx,
235 );
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use std::{cell::Cell, rc::Rc};
242
243 use gpui::{
244 Bounds, Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render,
245 Styled as _, TestAppContext, Window, div, point, px, size,
246 };
247
248 use super::SelectableText;
249 use crate::{TextSelection, TextSelectionHandle, TextSelectionLayer};
250
251 struct SelectableTextTestView;
252
253 impl Render for SelectableTextTestView {
254 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
255 div().size_full().child(TextSelectionLayer).child(
256 div()
257 .w(px(240.))
258 .h(px(32.))
259 .child(SelectableText::new("local", "alpha beta")),
260 )
261 }
262 }
263
264 #[gpui::test]
265 fn explicit_handle_constructor_preserves_document_contract(cx: &mut TestAppContext) {
266 cx.update(|cx| {
267 let handle = TextSelectionHandle::new("alpha beta", cx);
268 let _ = SelectableText::with_handle("plain", handle, "alpha beta").document_order(42);
269 });
270 }
271
272 #[gpui::test]
273 fn local_handle_participates_in_window_selection(cx: &mut TestAppContext) {
274 let (_, cx) = cx.add_window_view(|_, _| SelectableTextTestView);
275 cx.update(|window, cx| {
276 let _ = window.draw(cx);
277 });
278
279 cx.simulate_mouse_down(
280 gpui::point(px(1.), px(12.)),
281 MouseButton::Left,
282 Modifiers::default(),
283 );
284 cx.simulate_mouse_move(
285 gpui::point(px(220.), px(12.)),
286 Some(MouseButton::Left),
287 Modifiers::default(),
288 );
289 cx.simulate_mouse_up(
290 gpui::point(px(220.), px(12.)),
291 MouseButton::Left,
292 Modifiers::default(),
293 );
294 cx.update(|window, cx| {
295 let _ = window.draw(cx);
296 assert_eq!(TextSelection::selected_text(window, cx), "alpha beta");
297 });
298 }
299
300 struct CopyCallbackTestView {
301 handle: TextSelectionHandle,
302 }
303
304 impl Render for CopyCallbackTestView {
305 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
306 div()
307 .size_full()
308 .child(TextSelectionLayer)
309 .child(SelectableText::new("local", "alpha beta"))
310 .child(SelectableText::with_handle(
311 "custom",
312 self.handle.clone(),
313 "gamma",
314 ))
315 }
316 }
317
318 #[gpui::test]
319 fn paint_does_not_resolve_window_selected_text(cx: &mut TestAppContext) {
320 let copies = Rc::new(Cell::new(0));
321 let handle = cx.update(|cx| {
322 let handle = TextSelectionHandle::new("gamma", cx);
323 let copies = copies.clone();
324 handle.copy_with(
325 move |_| {
326 copies.set(copies.get() + 1);
327 "gamma".into()
328 },
329 cx,
330 );
331 handle.set_local_selection(true, cx);
332 handle
333 });
334 let (_, cx) = cx.add_window_view(|_, _| CopyCallbackTestView { handle });
335 cx.update(|window, cx| {
336 let _ = window.draw(cx);
337 });
338 copies.set(0);
339
340 cx.update(|window, cx| {
341 let _ = window.draw(cx);
342 });
343
344 assert_eq!(copies.get(), 0);
345 }
346
347 #[test]
348 fn wrapped_selection_paints_full_width_middle_lines() {
349 let bounds = Bounds::new(point(px(10.), px(20.)), size(px(100.), px(100.)));
350 let quads = super::selection_quad_bounds(
351 point(px(40.), px(20.)),
352 point(px(30.), px(80.)),
353 bounds,
354 px(20.),
355 );
356
357 assert_eq!(
358 quads,
359 vec![
360 Bounds::from_corners(point(px(40.), px(20.)), point(px(110.), px(40.))),
361 Bounds::from_corners(point(px(10.), px(40.)), point(px(110.), px(80.))),
362 Bounds::from_corners(point(px(10.), px(80.)), point(px(30.), px(100.))),
363 ]
364 );
365 }
366}