Skip to main content

markdown/
selectable.rs

1//! Text you can select with the pointer and copy out of.
2//!
3//! [`render`] handles pointer selection; [`surface`] adds keyboard focus and copy.
4//! Selection state stays with the caller so lists can share one selection.
5
6use crate::{BlockLayouts, Cursor, Doc, Editing, Selection};
7use gpui::{
8    AnyElement, Context, CursorStyle, DispatchPhase, ElementId, MouseButton, MouseDownEvent,
9    MouseMoveEvent, Window, canvas, div, prelude::*,
10};
11use std::rc::Rc;
12
13/// What the pointer did over the text.
14pub enum Pointer {
15    /// Pressed here — the start of a selection.
16    Down(Cursor),
17    /// Moved here with the button still down.
18    Move(Cursor),
19    /// Let go. Whatever the selection had become is what it is.
20    Up,
21}
22
23/// Render `doc` with `selection` painted in it, reporting what the pointer does.
24///
25/// `dragging` is the caller's: a move only extends a selection that a press
26/// started, and which item that press landed in is not something one block of
27/// text can know. It has to be `true` for at most one block at a time — a
28/// block that is told it is dragging follows the pointer over the whole
29/// window, so two of them would both extend on every move.
30///
31/// Releasing is answered twice over — on the text and off it — because a drag
32/// that ends past the edge of a paragraph is the ordinary way to select to the
33/// end of one. Moves are read the same way: `on_mouse_move` is delivered only
34/// while the pointer is over this element's hitbox, so a drag off the text —
35/// or under something painted over it — would otherwise stop extending where
36/// it crossed the edge. [`BlockLayouts::hit`] resolves a point outside the
37/// text to the nearest line, which is what makes the off-hitbox move
38/// answerable at all.
39#[expect(
40    clippy::too_many_arguments,
41    reason = "a document, its selection, and a gesture"
42)]
43pub fn render<V: 'static>(
44    id: impl Into<ElementId>,
45    doc: &Doc,
46    layouts: &BlockLayouts,
47    selection: Option<Selection>,
48    dragging: bool,
49    window: &mut Window,
50    cx: &mut Context<V>,
51    on_pointer: impl Fn(&mut V, Pointer, &mut Context<V>) + 'static,
52) -> AnyElement {
53    render_with(
54        id,
55        doc,
56        layouts,
57        selection,
58        dragging,
59        Editing::default(),
60        window,
61        cx,
62        on_pointer,
63    )
64}
65
66/// [`render`], painting what `editing` asks for besides. Its `selection`,
67/// `caret_on` and `layouts` are ignored.
68#[expect(
69    clippy::too_many_arguments,
70    reason = "a document, its selection, what else it paints, and a gesture"
71)]
72pub fn render_with<V: 'static>(
73    id: impl Into<ElementId>,
74    doc: &Doc,
75    layouts: &BlockLayouts,
76    selection: Option<Selection>,
77    dragging: bool,
78    editing: Editing,
79    window: &mut Window,
80    cx: &mut Context<V>,
81    on_pointer: impl Fn(&mut V, Pointer, &mut Context<V>) + 'static,
82) -> AnyElement {
83    let on_pointer = Rc::new(on_pointer);
84    let (down, moved, up, off) = (
85        on_pointer.clone(),
86        on_pointer.clone(),
87        on_pointer.clone(),
88        on_pointer,
89    );
90    let (at_down, at_move) = (layouts.clone(), layouts.clone());
91    div()
92        .id(id)
93        .cursor(CursorStyle::IBeam)
94        .on_mouse_down(
95            MouseButton::Left,
96            cx.listener(move |view, event: &MouseDownEvent, _, cx| {
97                if let Some(cursor) = at_down.hit(event.position) {
98                    down(view, Pointer::Down(cursor), cx);
99                }
100            }),
101        )
102        // Registered in the paint phase, from a canvas that occupies nothing:
103        // a window listener is the only one that hears a move the hitbox does
104        // not cover, and `Window::on_mouse_event` may only be called there.
105        .children(dragging.then(|| {
106            let view = cx.entity();
107            canvas(
108                |_, _, _| (),
109                move |_, _, window, _| {
110                    window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
111                        if phase != DispatchPhase::Bubble
112                            || event.pressed_button != Some(MouseButton::Left)
113                        {
114                            return;
115                        }
116                        if let Some(cursor) = at_move.hit(event.position) {
117                            view.update(cx, |view, cx| {
118                                moved(view, Pointer::Move(cursor), cx);
119                            });
120                        }
121                    });
122                },
123            )
124            .absolute()
125            .size_0()
126        }))
127        .on_mouse_up(
128            MouseButton::Left,
129            cx.listener(move |view, _, _, cx| up(view, Pointer::Up, cx)),
130        )
131        .on_mouse_up_out(
132            MouseButton::Left,
133            cx.listener(move |view, _, _, cx| off(view, Pointer::Up, cx)),
134        )
135        .child(crate::render_with(
136            doc,
137            Editing {
138                selection,
139                // Read-only text has no caret. Without this a collapsed
140                // selection — every press that starts one — would blink an
141                // insertion point in text nobody can type into.
142                caret_on: false,
143                layouts: Some(layouts),
144                ..editing
145            },
146            window,
147            cx,
148        ))
149        .into_any_element()
150}
151
152/// The text a selection covers, as it would be pasted.
153///
154/// [`Doc::spans`] answers in parts — a paragraph, a cell, a line of a fence —
155/// and a newline between them is what puts a multi-block selection back
156/// together.
157pub fn copied(doc: &Doc, selection: Selection) -> String {
158    doc.spans(selection)
159        .into_iter()
160        .filter_map(|(at, range)| {
161            let text = &doc.blocks.get(at.block)?.text_at(at.part)?.text;
162            text.get(range).map(str::to_owned)
163        })
164        .collect::<Vec<_>>()
165        .join("\n")
166}
167
168gpui::actions!(selectable, [Copy]);
169
170struct Bindings;
171impl gpui::Global for Bindings {}
172
173/// Wrap selectable content with focus-on-click and platform copy shortcuts.
174/// Rebuild when the document or selection changes. Keep `focus` stable per surface.
175pub fn surface(
176    focus: &gpui::FocusHandle,
177    doc: &Doc,
178    selection: Option<Selection>,
179    cx: &mut gpui::App,
180) -> gpui::Div {
181    if !cx.has_global::<Bindings>() {
182        let chord = if cfg!(target_os = "macos") {
183            "cmd-c"
184        } else {
185            "ctrl-c"
186        };
187        cx.bind_keys([gpui::KeyBinding::new(chord, Copy, Some("SelectableText"))]);
188        cx.set_global(Bindings);
189    }
190    let text = selection
191        .map(|selection| copied(doc, selection))
192        .unwrap_or_default();
193    let focus = focus.clone();
194    div()
195        .key_context("SelectableText")
196        .track_focus(&focus)
197        .on_mouse_down(MouseButton::Left, move |_, window, cx| {
198            window.focus(&focus, cx)
199        })
200        .on_action(move |_: &Copy, _, cx| {
201            if !text.is_empty() {
202                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone()));
203            }
204        })
205}