Skip to main content

gpui_base/
selectable_text.rs

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::{TextSelection, TextSelectionHandle, TextSelectionRegistration, TextSelectionRun};
10
11/// Plain text that participates in the window-scoped [`TextSelection`].
12///
13/// Use [`Self::new`] for an independent run, or [`Self::with_handle`] when
14/// several elements form one selectable document.
15///
16/// Applications must render one [`crate::TextSelectionLayer`] above their
17/// content and call [`crate::init`] during startup. Selection and copy then
18/// work without depending on `gpui-component`.
19pub 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    /// Creates a run that owns its own selection.
31    pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
32        Self::build(id.into(), None, text.into())
33    }
34
35    /// Creates a run that joins the document `handle` belongs to.
36    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    /// Places this run in reading order among the others sharing its handle.
57    pub fn document_order(mut self, order: u64) -> Self {
58        self.document_order = order;
59        self
60    }
61
62    /// Sets the text style the run is laid out and painted with.
63    pub fn text_style(mut self, style: TextStyleRefinement) -> Self {
64        self.text_style = Some(style);
65        self
66    }
67
68    /// Overrides the selection background, which defaults to the theme's
69    /// `colors.selection` token.
70    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 selected_text_before = TextSelection::selected_text(window, 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 selected_text_before != TextSelection::selected_text(window, 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 gpui::{
242        Bounds, Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render,
243        Styled as _, TestAppContext, Window, div, point, px, size,
244    };
245
246    use super::SelectableText;
247    use crate::{TextSelection, TextSelectionHandle, TextSelectionLayer};
248
249    struct SelectableTextTestView;
250
251    impl Render for SelectableTextTestView {
252        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
253            div().size_full().child(TextSelectionLayer).child(
254                div()
255                    .w(px(240.))
256                    .h(px(32.))
257                    .child(SelectableText::new("local", "alpha beta")),
258            )
259        }
260    }
261
262    #[gpui::test]
263    fn explicit_handle_constructor_preserves_document_contract(cx: &mut TestAppContext) {
264        cx.update(|cx| {
265            let handle = TextSelectionHandle::new("alpha beta", cx);
266            let _ = SelectableText::with_handle("plain", handle, "alpha beta").document_order(42);
267        });
268    }
269
270    #[gpui::test]
271    fn local_handle_participates_in_window_selection(cx: &mut TestAppContext) {
272        let (_, cx) = cx.add_window_view(|_, _| SelectableTextTestView);
273        cx.update(|window, cx| {
274            let _ = window.draw(cx);
275        });
276
277        cx.simulate_mouse_down(
278            gpui::point(px(1.), px(12.)),
279            MouseButton::Left,
280            Modifiers::default(),
281        );
282        cx.simulate_mouse_move(
283            gpui::point(px(220.), px(12.)),
284            Some(MouseButton::Left),
285            Modifiers::default(),
286        );
287        cx.simulate_mouse_up(
288            gpui::point(px(220.), px(12.)),
289            MouseButton::Left,
290            Modifiers::default(),
291        );
292        cx.update(|window, cx| {
293            let _ = window.draw(cx);
294            assert_eq!(TextSelection::selected_text(window, cx), "alpha beta");
295        });
296    }
297
298    #[test]
299    fn wrapped_selection_paints_full_width_middle_lines() {
300        let bounds = Bounds::new(point(px(10.), px(20.)), size(px(100.), px(100.)));
301        let quads = super::selection_quad_bounds(
302            point(px(40.), px(20.)),
303            point(px(30.), px(80.)),
304            bounds,
305            px(20.),
306        );
307
308        assert_eq!(
309            quads,
310            vec![
311                Bounds::from_corners(point(px(40.), px(20.)), point(px(110.), px(40.))),
312                Bounds::from_corners(point(px(10.), px(40.)), point(px(110.), px(80.))),
313                Bounds::from_corners(point(px(10.), px(80.)), point(px(30.), px(100.))),
314            ]
315        );
316    }
317}