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        handle.register(
192            TextSelectionRegistration::new(hitbox.clone(), bounds)
193                .with_document_order(self.document_order)
194                .with_text_bounds(vec![bounds]),
195            window,
196            cx,
197        );
198        hitbox
199    }
200
201    fn paint(
202        &mut self,
203        global_id: Option<&GlobalElementId>,
204        inspector_id: Option<&InspectorElementId>,
205        bounds: Bounds<Pixels>,
206        handle: &mut Self::RequestLayoutState,
207        _: &mut Self::PrepaintState,
208        window: &mut Window,
209        cx: &mut App,
210    ) {
211        let layout = self.styled_text.layout().clone();
212        let selected_text_before = TextSelection::selected_text(window, cx);
213        let projection = handle.update_runs(
214            &[
215                TextSelectionRun::new(self.text.clone(), layout.clone(), bounds)
216                    .with_document_order(self.document_order),
217            ],
218            cx,
219        );
220        if selected_text_before != TextSelection::selected_text(window, cx) {
221            window.refresh();
222        }
223        let color = self
224            .selection_color
225            .unwrap_or_else(|| crate::Theme::global(cx).tokens.colors.selection);
226        for range in projection.ranges().iter().flatten().cloned() {
227            Self::paint_selection(&layout, range, color, window);
228        }
229        self.styled_text.paint(
230            global_id,
231            inspector_id,
232            bounds,
233            &mut (),
234            &mut (),
235            window,
236            cx,
237        );
238    }
239}
240
241#[cfg(test)]
242mod tests {
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    #[test]
301    fn wrapped_selection_paints_full_width_middle_lines() {
302        let bounds = Bounds::new(point(px(10.), px(20.)), size(px(100.), px(100.)));
303        let quads = super::selection_quad_bounds(
304            point(px(40.), px(20.)),
305            point(px(30.), px(80.)),
306            bounds,
307            px(20.),
308        );
309
310        assert_eq!(
311            quads,
312            vec![
313                Bounds::from_corners(point(px(40.), px(20.)), point(px(110.), px(40.))),
314                Bounds::from_corners(point(px(10.), px(40.)), point(px(110.), px(80.))),
315                Bounds::from_corners(point(px(10.), px(80.)), point(px(30.), px(100.))),
316            ]
317        );
318    }
319}