Skip to main content

repose_ui/
selection.rs

1//! Selectable text as a fluent modifier on `Text` views.
2//!
3//! Mirrors Compose-style selection for a single `Text`:
4//! - Drag to select (byte range, multi-line highlight)
5//! - Double-tap -> select word under pointer
6//! - Triple-tap -> select all
7//! - Shift+click / shift+drag -> extend from previous anchor
8//! - Primary selection (middle-click paste) + Ctrl/Cmd+C via `Action::Copy`
9//!
10//! The highlight itself is painted by the layout engine from the node's real
11//! text layout (Compose's `SelectionController` drawing
12//! `getPathForRange`), so it tracks wrapping, line height and text alignment.
13
14use 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
24// Shared tap-timing with TextFieldState (textfield.rs): 300ms double-tap
25// window, 12px slop, cycling single -> double -> triple.
26const DOUBLE_TAP_MS: u64 = 300;
27const TRIPLE_TAP_MS: u64 = 500;
28const TAP_SLOP_PX: f32 = 12.0;
29
30/// Fluent selectable modifier for `Text` views.
31///
32/// Call after styling: `Text(...).size(16.0).selectable(|sel| ...)`.
33pub 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
79/// Backward-compatible free function.
80pub 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    // Tap counting for double/triple
109    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    // Map a pointer position to a byte offset using the geometry the engine
126    // published for this node, so wrapping and line height match what is painted.
127    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            // Tap counting
168            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                // Triple-tap: select all
207                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                // Double-tap: select word
220                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            // Single tap / start drag
233            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}