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}