Skip to main content

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