1use std::cell::RefCell;
15use std::rc::Rc;
16
17use repose_core::prelude::*;
18use repose_core::{CursorIcon, FontStyle, PointerEvent, TextRange, TextSelection, View};
19use web_time::{Duration, Instant};
20
21use crate::textfield::{TextFieldMetrics, index_for_xy_bytes_with_metrics, word_range};
22use crate::{Text, TextStyle};
23
24const DOUBLE_TAP_MS: u64 = 300;
27const TRIPLE_TAP_MS: u64 = 500;
28const TAP_SLOP_PX: f32 = 12.0;
29
30pub trait SelectableTextExt {
34 fn selectable(self, on_selection_change: impl Fn(Option<(usize, usize)>) + 'static) -> View;
35}
36
37impl SelectableTextExt for View {
38 fn selectable(self, on_selection_change: impl Fn(Option<(usize, usize)>) + 'static) -> View {
39 let (text, metrics) = match &self.kind {
40 ViewKind::Text {
41 text,
42 font_size,
43 font_family,
44 font_weight,
45 font_style,
46 letter_spacing,
47 line_height,
48 font_variation_settings,
49 ..
50 } => {
51 let font_px = font_size.to_px().0;
52 let metrics = TextFieldMetrics {
53 font_px,
54 font_family: *font_family,
55 font_weight: font_weight.0,
56 font_style: if matches!(font_style, FontStyle::Italic) {
57 1
58 } else {
59 0
60 },
61 letter_spacing_px: letter_spacing.to_px().0,
62 font_variation_settings: font_variation_settings
63 .as_ref()
64 .map(|settings| settings.to_string()),
65 line_height_px: if line_height.0 > 0.0 {
66 line_height.to_px().0
67 } else {
68 font_px
69 },
70 };
71 (text.clone(), metrics)
72 }
73 _ => return self,
74 };
75 make_selectable(self, text, metrics, on_selection_change)
76 }
77}
78
79pub fn SelectableText(
81 text: impl Into<String>,
82 font_size_sp: Sp,
83 on_selection_change: impl Fn(Option<(usize, usize)>) + 'static,
84) -> View {
85 let text: String = text.into();
86 let v = Text(text.clone()).size(font_size_sp);
87 let font_px = font_size_sp.to_px().0;
88 let metrics = TextFieldMetrics {
89 font_px,
90 line_height_px: font_px,
91 ..TextFieldMetrics::default()
92 };
93 make_selectable(v, text, metrics, on_selection_change)
94}
95
96fn make_selectable(
97 mut v: View,
98 text: String,
99 metrics: TextFieldMetrics,
100 on_selection_change: impl Fn(Option<(usize, usize)>) + 'static,
101) -> View {
102 let text_for_handlers = text.clone();
103
104 let selection = TextSelection::clone(&remember(TextSelection::default));
105 let anchor: Rc<RefCell<usize>> = remember(|| RefCell::new(0));
106 let dragging: Rc<RefCell<bool>> = remember(|| RefCell::new(false));
107
108 let last_tap_time: Rc<RefCell<Option<Instant>>> = remember(|| RefCell::new(None));
110 let last_tap_pos: Rc<RefCell<Option<(f32, f32)>>> = remember(|| RefCell::new(None));
111 let tap_count: Rc<RefCell<u8>> = remember(|| RefCell::new(0));
112
113 let callback = Rc::new(on_selection_change);
114 let metrics = Rc::new(metrics);
115
116 let set_sel = {
117 let selection = selection.clone();
118 let callback = callback.clone();
119 move |s: Option<(usize, usize)>| {
120 selection.set_range(s.map(|(a, b)| TextRange { start: a, end: b }));
121 callback(s);
122 }
123 };
124
125 let hit_test = {
128 let text = text_for_handlers.clone();
129 let selection = selection.clone();
130 let metrics = metrics.clone();
131 move |x: f32, y: f32| -> usize {
132 let geo = selection.geometry();
133 if geo.wrap_width_px <= 0.0 {
134 return 0;
135 }
136 let layout_metrics = TextFieldMetrics {
137 line_height_px: if geo.line_height_px > 0.0 {
138 geo.line_height_px
139 } else {
140 metrics.line_height_px
141 },
142 ..(*metrics).clone()
143 };
144 index_for_xy_bytes_with_metrics(
145 &text,
146 geo.wrap_width_px,
147 (x - geo.origin.0).max(0.0),
148 (y - geo.origin.1).max(0.0),
149 &layout_metrics,
150 )
151 }
152 };
153
154 let on_down = {
155 let text = text_for_handlers.clone();
156 let selection = selection.clone();
157 let anchor = anchor.clone();
158 let dragging = dragging.clone();
159 let last_tap_time = last_tap_time.clone();
160 let last_tap_pos = last_tap_pos.clone();
161 let tap_count = tap_count.clone();
162 let set_sel = set_sel.clone();
163 let hit_test = hit_test.clone();
164 move |ev: PointerEvent| {
165 let byte = hit_test(ev.position.x, ev.position.y);
166
167 let now = Instant::now();
169 let pos = (ev.position.x, ev.position.y);
170 let mut count = *tap_count.borrow();
171 let mut is_multi = false;
172 if let (Some(t), Some(p)) = (*last_tap_time.borrow(), *last_tap_pos.borrow()) {
173 let dt = now.saturating_duration_since(t);
174 let dist = ((pos.0 - p.0).powi(2) + (pos.1 - p.1).powi(2)).sqrt();
175 if count == 0 {
176 count = 1;
177 } else if count == 1
178 && dt < Duration::from_millis(DOUBLE_TAP_MS)
179 && dist < TAP_SLOP_PX
180 {
181 count = 2;
182 is_multi = true;
183 } else if count >= 2
184 && dt < Duration::from_millis(TRIPLE_TAP_MS)
185 && dist < TAP_SLOP_PX
186 {
187 count = if count >= 3 {
188 1
189 } else {
190 count.saturating_add(1)
191 };
192 is_multi = true;
193 } else {
194 count = 1;
195 }
196 } else {
197 count = 1;
198 }
199 *tap_count.borrow_mut() = count;
200 *last_tap_time.borrow_mut() = Some(now);
201 *last_tap_pos.borrow_mut() = Some(pos);
202
203 let shift = ev.modifiers.shift;
204
205 if count >= 3 {
206 let sel = Some((0, text.len()));
208 *anchor.borrow_mut() = 0;
209 set_sel(sel);
210 *dragging.borrow_mut() = false;
211 if !text.is_empty() {
212 repose_core::clipboard::set_primary_selection(&text);
213 }
214 let _ = is_multi;
215 return;
216 }
217
218 if count == 2 {
219 let (s, e) = word_range(&text, byte);
221 let sel = Some((s, e));
222 *anchor.borrow_mut() = s;
223 set_sel(sel);
224 *dragging.borrow_mut() = true;
225 if e > s {
226 repose_core::clipboard::set_primary_selection(&text[s..e]);
227 }
228 let _ = is_multi;
229 return;
230 }
231
232 if shift {
234 let a = selection
235 .range()
236 .map(|range| {
237 let _ = range;
238 *anchor.borrow()
239 })
240 .unwrap_or(*anchor.borrow());
241 let sel = Some((a.min(byte), a.max(byte)));
242 set_sel(sel);
243 } else {
244 *anchor.borrow_mut() = byte;
245 set_sel(Some((byte, byte)));
246 }
247 *dragging.borrow_mut() = true;
248 let _ = is_multi;
249 }
250 };
251
252 let on_move = {
253 let text = text_for_handlers.clone();
254 let anchor = anchor.clone();
255 let dragging = dragging.clone();
256 let set_sel = set_sel.clone();
257 let hit_test = hit_test.clone();
258 move |ev: PointerEvent| {
259 if !*dragging.borrow() {
260 return;
261 }
262 let byte = hit_test(ev.position.x, ev.position.y);
263 let a = *anchor.borrow();
264 let sel = Some((a.min(byte), a.max(byte)));
265 set_sel(sel);
266 if let Some((s, e)) = sel
267 && e > s
268 {
269 repose_core::clipboard::set_primary_selection(&text[s..e]);
270 }
271 }
272 };
273
274 let on_up = {
275 let text = text_for_handlers.clone();
276 let selection = selection.clone();
277 let dragging = dragging.clone();
278 let set_sel = set_sel.clone();
279 move |_ev: PointerEvent| {
280 *dragging.borrow_mut() = false;
281 let sel = selection.range().map(|r| (r.start, r.end));
282 if let Some((a, b)) = sel {
283 let s = a.min(b);
284 let e = a.max(b);
285 if e > s {
286 repose_core::clipboard::set_primary_selection(&text[s..e]);
287 }
288 }
289 set_sel(sel);
290 }
291 };
292
293 let on_cancel = {
294 let dragging = dragging.clone();
295 let tap_count = tap_count.clone();
296 let last_tap_time = last_tap_time.clone();
297 let last_tap_pos = last_tap_pos.clone();
298 move |_ev: PointerEvent| {
299 *dragging.borrow_mut() = false;
300 *tap_count.borrow_mut() = 0;
301 *last_tap_time.borrow_mut() = None;
302 *last_tap_pos.borrow_mut() = None;
303 }
304 };
305
306 v.modifier = v
307 .modifier
308 .on_pointer_down(on_down)
309 .on_pointer_move(on_move)
310 .on_pointer_up(on_up)
311 .on_pointer_cancel(on_cancel)
312 .text_selection(selection.clone())
313 .cursor(CursorIcon::Text)
314 .on_action({
315 let selection = selection.clone();
316 let text = text_for_handlers.clone();
317 move |action| match action {
318 repose_core::shortcuts::Action::Copy => {
319 if let Some(range) = selection.range() {
320 let s = range.min();
321 let e = range.max();
322 if e > s {
323 repose_core::clipboard::copy_to_clipboard(&text[s..e]);
324 return true;
325 }
326 }
327 false
328 }
329 repose_core::shortcuts::Action::SelectAll => {
330 let len = text.len();
331 selection.set_range(Some(TextRange { start: 0, end: len }));
332 if len > 0 {
333 repose_core::clipboard::set_primary_selection(&text);
334 }
335 true
336 }
337 _ => false,
338 }
339 });
340
341 v
342}