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, render_with};
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    let on_pointer = Rc::new(on_pointer);
54    let (down, moved, up, off) = (
55        on_pointer.clone(),
56        on_pointer.clone(),
57        on_pointer.clone(),
58        on_pointer,
59    );
60    let (at_down, at_move) = (layouts.clone(), layouts.clone());
61    div()
62        .id(id)
63        .cursor(CursorStyle::IBeam)
64        .on_mouse_down(
65            MouseButton::Left,
66            cx.listener(move |view, event: &MouseDownEvent, _, cx| {
67                if let Some(cursor) = at_down.hit(event.position) {
68                    down(view, Pointer::Down(cursor), cx);
69                }
70            }),
71        )
72        // Registered in the paint phase, from a canvas that occupies nothing:
73        // a window listener is the only one that hears a move the hitbox does
74        // not cover, and `Window::on_mouse_event` may only be called there.
75        .children(dragging.then(|| {
76            let view = cx.entity();
77            canvas(
78                |_, _, _| (),
79                move |_, _, window, _| {
80                    window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
81                        if phase != DispatchPhase::Bubble
82                            || event.pressed_button != Some(MouseButton::Left)
83                        {
84                            return;
85                        }
86                        if let Some(cursor) = at_move.hit(event.position) {
87                            view.update(cx, |view, cx| {
88                                moved(view, Pointer::Move(cursor), cx);
89                            });
90                        }
91                    });
92                },
93            )
94            .absolute()
95            .size_0()
96        }))
97        .on_mouse_up(
98            MouseButton::Left,
99            cx.listener(move |view, _, _, cx| up(view, Pointer::Up, cx)),
100        )
101        .on_mouse_up_out(
102            MouseButton::Left,
103            cx.listener(move |view, _, _, cx| off(view, Pointer::Up, cx)),
104        )
105        .child(render_with(
106            doc,
107            Editing {
108                selection,
109                // Read-only text has no caret. Without this a collapsed
110                // selection — every press that starts one — would blink an
111                // insertion point in text nobody can type into.
112                caret_on: false,
113                layouts: Some(layouts),
114                ..Editing::default()
115            },
116            window,
117            cx,
118        ))
119        .into_any_element()
120}
121
122/// The text a selection covers, as it would be pasted.
123///
124/// [`Doc::spans`] answers in parts — a paragraph, a cell, a line of a fence —
125/// and a newline between them is what puts a multi-block selection back
126/// together.
127pub fn copied(doc: &Doc, selection: Selection) -> String {
128    doc.spans(selection)
129        .into_iter()
130        .filter_map(|(at, range)| {
131            let text = &doc.blocks.get(at.block)?.text_at(at.part)?.text;
132            text.get(range).map(str::to_owned)
133        })
134        .collect::<Vec<_>>()
135        .join("\n")
136}
137
138gpui::actions!(selectable, [Copy]);
139
140struct Bindings;
141impl gpui::Global for Bindings {}
142
143/// Wrap selectable content with focus-on-click and platform copy shortcuts.
144/// Rebuild when the document or selection changes. Keep `focus` stable per surface.
145pub fn surface(
146    focus: &gpui::FocusHandle,
147    doc: &Doc,
148    selection: Option<Selection>,
149    cx: &mut gpui::App,
150) -> gpui::Div {
151    if !cx.has_global::<Bindings>() {
152        let chord = if cfg!(target_os = "macos") {
153            "cmd-c"
154        } else {
155            "ctrl-c"
156        };
157        cx.bind_keys([gpui::KeyBinding::new(chord, Copy, Some("SelectableText"))]);
158        cx.set_global(Bindings);
159    }
160    let text = selection
161        .map(|selection| copied(doc, selection))
162        .unwrap_or_default();
163    let focus = focus.clone();
164    div()
165        .key_context("SelectableText")
166        .track_focus(&focus)
167        .on_mouse_down(MouseButton::Left, move |_, window, cx| {
168            window.focus(&focus, cx)
169        })
170        .on_action(move |_: &Copy, _, cx| {
171            if !text.is_empty() {
172                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone()));
173            }
174        })
175}