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