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, ElementId, MouseButton, MouseDownEvent, MouseMoveEvent,
9    Window, 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.
28///
29/// Releasing is answered twice over — on the text and off it — because a drag
30/// that ends past the edge of a paragraph is the ordinary way to select to the
31/// end of one.
32#[expect(
33    clippy::too_many_arguments,
34    reason = "a document, its selection, and a gesture"
35)]
36pub fn render<V: 'static>(
37    id: impl Into<ElementId>,
38    doc: &Doc,
39    layouts: &BlockLayouts,
40    selection: Option<Selection>,
41    dragging: bool,
42    window: &mut Window,
43    cx: &mut Context<V>,
44    on_pointer: impl Fn(&mut V, Pointer, &mut Context<V>) + 'static,
45) -> AnyElement {
46    let on_pointer = Rc::new(on_pointer);
47    let (down, moved, up, off) = (
48        on_pointer.clone(),
49        on_pointer.clone(),
50        on_pointer.clone(),
51        on_pointer,
52    );
53    let (at_down, at_move) = (layouts.clone(), layouts.clone());
54    div()
55        .id(id)
56        .cursor(CursorStyle::IBeam)
57        .on_mouse_down(
58            MouseButton::Left,
59            cx.listener(move |view, event: &MouseDownEvent, _, cx| {
60                if let Some(cursor) = at_down.hit(event.position) {
61                    down(view, Pointer::Down(cursor), cx);
62                }
63            }),
64        )
65        .on_mouse_move(cx.listener(move |view, event: &MouseMoveEvent, _, cx| {
66            if dragging && let Some(cursor) = at_move.hit(event.position) {
67                moved(view, Pointer::Move(cursor), cx);
68            }
69        }))
70        .on_mouse_up(
71            MouseButton::Left,
72            cx.listener(move |view, _, _, cx| up(view, Pointer::Up, cx)),
73        )
74        .on_mouse_up_out(
75            MouseButton::Left,
76            cx.listener(move |view, _, _, cx| off(view, Pointer::Up, cx)),
77        )
78        .child(render_with(
79            doc,
80            Editing {
81                selection,
82                // Read-only text has no caret. Without this a collapsed
83                // selection — every press that starts one — would blink an
84                // insertion point in text nobody can type into.
85                caret_on: false,
86                layouts: Some(layouts),
87                ..Editing::default()
88            },
89            window,
90            cx,
91        ))
92        .into_any_element()
93}
94
95/// The text a selection covers, as it would be pasted.
96///
97/// [`Doc::spans`] answers in parts — a paragraph, a cell, a line of a fence —
98/// and a newline between them is what puts a multi-block selection back
99/// together.
100pub fn copied(doc: &Doc, selection: Selection) -> String {
101    doc.spans(selection)
102        .into_iter()
103        .filter_map(|(at, range)| {
104            let text = &doc.blocks.get(at.block)?.text_at(at.part)?.text;
105            text.get(range).map(str::to_owned)
106        })
107        .collect::<Vec<_>>()
108        .join("\n")
109}
110
111gpui::actions!(selectable, [Copy]);
112
113struct Bindings;
114impl gpui::Global for Bindings {}
115
116/// Wrap selectable content with focus-on-click and platform copy shortcuts.
117/// Rebuild when the document or selection changes. Keep `focus` stable per surface.
118pub fn surface(
119    focus: &gpui::FocusHandle,
120    doc: &Doc,
121    selection: Option<Selection>,
122    cx: &mut gpui::App,
123) -> gpui::Div {
124    if !cx.has_global::<Bindings>() {
125        let chord = if cfg!(target_os = "macos") {
126            "cmd-c"
127        } else {
128            "ctrl-c"
129        };
130        cx.bind_keys([gpui::KeyBinding::new(chord, Copy, Some("SelectableText"))]);
131        cx.set_global(Bindings);
132    }
133    let text = selection
134        .map(|selection| copied(doc, selection))
135        .unwrap_or_default();
136    let focus = focus.clone();
137    div()
138        .key_context("SelectableText")
139        .track_focus(&focus)
140        .on_mouse_down(MouseButton::Left, move |_, window, cx| {
141            window.focus(&focus, cx)
142        })
143        .on_action(move |_: &Copy, _, cx| {
144            if !text.is_empty() {
145                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone()));
146            }
147        })
148}