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
10use std::cell::RefCell;
11use std::rc::Rc;
12
13use repose_core::prelude::*;
14use repose_core::{Brush, CursorIcon, PointerEvent, Rect, Scene, SceneNode, View};
15use unicode_segmentation::UnicodeSegmentation;
16use web_time::{Duration, Instant};
17
18use crate::textfield::{caret_xy_for_byte, index_for_xy_bytes};
19use crate::{Text, TextStyle};
20
21const DOUBLE_TAP_MS: u64 = 300;
22const TRIPLE_TAP_MS: u64 = 500;
23const TAP_SLOP_PX: f32 = 12.0;
24
25/// Fluent selectable modifier for `Text` views.
26///
27/// Call after styling: `Text(...).size(16.0).selectable(|sel| ...)`.
28pub trait SelectableTextExt {
29    fn selectable(
30        self,
31        on_selection_change: impl Fn(Option<(usize, usize)>) + 'static,
32    ) -> View;
33}
34
35impl SelectableTextExt for View {
36    fn selectable(
37        self,
38        on_selection_change: impl Fn(Option<(usize, usize)>) + 'static,
39    ) -> View {
40        let (text, font_size_dp) = match &self.kind {
41            ViewKind::Text {
42                text,
43                font_size,
44                ..
45            } => (text.clone(), *font_size),
46            _ => return self,
47        };
48        make_selectable(self, text, font_size_dp, on_selection_change)
49    }
50}
51
52/// Backward-compatible free function.
53pub fn SelectableText(
54    text: impl Into<String>,
55    font_size_dp: f32,
56    on_selection_change: impl Fn(Option<(usize, usize)>) + 'static,
57) -> View {
58    let text: String = text.into();
59    let v = Text(text.clone()).size(font_size_dp);
60    make_selectable(v, text, font_size_dp, on_selection_change)
61}
62
63fn make_selectable(
64    mut v: View,
65    text: String,
66    font_size_dp: f32,
67    on_selection_change: impl Fn(Option<(usize, usize)>) + 'static,
68) -> View {
69    let text_for_handlers = text.clone();
70    let text_for_paint = text.clone();
71
72    let selection: Rc<RefCell<Option<(usize, usize)>>> =
73        remember(|| RefCell::new(None));
74    let anchor: Rc<RefCell<usize>> = remember(|| RefCell::new(0));
75    let dragging: Rc<RefCell<bool>> = remember(|| RefCell::new(false));
76    let last_rect: Rc<RefCell<Rect>> = remember(|| RefCell::new(Rect::default()));
77
78    // Tap counting for double/triple
79    let last_tap_time: Rc<RefCell<Option<Instant>>> = remember(|| RefCell::new(None));
80    let last_tap_pos: Rc<RefCell<Option<(f32, f32)>>> = remember(|| RefCell::new(None));
81    let tap_count: Rc<RefCell<u8>> = remember(|| RefCell::new(0));
82
83    let callback = Rc::new(on_selection_change);
84
85    let set_sel = {
86        let selection = selection.clone();
87        let callback = callback.clone();
88        move |s: Option<(usize, usize)>| {
89            *selection.borrow_mut() = s;
90            callback(s);
91        }
92    };
93
94    // --- helpers -----------------------------------------------------------
95
96    fn prev_grapheme_boundary(text: &str, byte: usize) -> usize {
97        let mut last = 0usize;
98        for (i, _) in text.grapheme_indices(true) {
99            if i >= byte {
100                break;
101            }
102            last = i;
103        }
104        last
105    }
106
107    fn next_grapheme_boundary(text: &str, byte: usize) -> usize {
108        for (i, _) in text.grapheme_indices(true) {
109            if i > byte {
110                return i;
111            }
112        }
113        text.len()
114    }
115
116    fn word_range(text: &str, byte: usize) -> (usize, usize) {
117        let byte = byte.min(text.len());
118        let is_word = |g: &str| g.chars().all(|c| c.is_alphanumeric() || c == '_');
119
120        let mut start = byte;
121        while start > 0 {
122            let p = prev_grapheme_boundary(text, start);
123            if is_word(&text[p..start]) {
124                start = p;
125            } else {
126                break;
127            }
128        }
129        let mut end = byte;
130        while end < text.len() {
131            let n = next_grapheme_boundary(text, end);
132            if is_word(&text[end..n]) {
133                end = n;
134            } else {
135                break;
136            }
137        }
138        if start == end {
139            let s = if byte == 0 {
140                0
141            } else {
142                prev_grapheme_boundary(text, byte)
143            };
144            let e = next_grapheme_boundary(text, byte);
145            (s, e.max(s))
146        } else {
147            (start, end)
148        }
149    }
150
151    // --- pointer handlers --------------------------------------------------
152
153    let on_down = {
154        let text = text_for_handlers.clone();
155        let selection = selection.clone();
156        let anchor = anchor.clone();
157        let dragging = dragging.clone();
158        let last_rect = last_rect.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        move |ev: PointerEvent| {
164            let r = *last_rect.borrow();
165            if r.w <= 0.0 || r.h <= 0.0 {
166                return;
167            }
168            let font_px = dp_to_px(font_size_dp) * text_scale().0;
169            let lx = (ev.position.x - r.x).max(0.0);
170            let ly = (ev.position.y - r.y).max(0.0);
171            let wrap_w = r.w.max(1.0);
172            let byte = index_for_xy_bytes(&text, font_px, wrap_w, lx, ly);
173
174            // Tap counting
175            let now = Instant::now();
176            let pos = (ev.position.x, ev.position.y);
177            let mut count = *tap_count.borrow();
178            let mut is_multi = false;
179            if let (Some(t), Some(p)) = (*last_tap_time.borrow(), *last_tap_pos.borrow()) {
180                let dt = now.saturating_duration_since(t);
181                let dist = ((pos.0 - p.0).powi(2) + (pos.1 - p.1).powi(2)).sqrt();
182                if dt < Duration::from_millis(DOUBLE_TAP_MS) && dist < TAP_SLOP_PX {
183                    count = count.saturating_add(1);
184                    is_multi = true;
185                } else if dt < Duration::from_millis(TRIPLE_TAP_MS) && dist < TAP_SLOP_PX {
186                    count = count.saturating_add(1);
187                    is_multi = true;
188                } else {
189                    count = 1;
190                }
191            } else {
192                count = 1;
193            }
194            *tap_count.borrow_mut() = count;
195            *last_tap_time.borrow_mut() = Some(now);
196            *last_tap_pos.borrow_mut() = Some(pos);
197
198            let shift = ev.modifiers.shift;
199
200            if count >= 3 {
201                // Triple-tap: select all
202                let sel = Some((0, text.len()));
203                *anchor.borrow_mut() = 0;
204                set_sel(sel);
205                *dragging.borrow_mut() = false;
206                if text.len() > 0 {
207                    repose_core::clipboard::set_primary_selection(&text);
208                }
209                let _ = is_multi;
210                return;
211            }
212
213            if count == 2 {
214                // Double-tap: select word
215                let (s, e) = word_range(&text, byte);
216                let sel = Some((s, e));
217                *anchor.borrow_mut() = s;
218                set_sel(sel);
219                *dragging.borrow_mut() = true;
220                if e > s {
221                    repose_core::clipboard::set_primary_selection(&text[s..e]);
222                }
223                let _ = is_multi;
224                return;
225            }
226
227            // Single tap / start drag
228            if shift {
229                let a = selection
230                    .borrow()
231                    .map(|(s, e)| {
232                        let _ = (s, e);
233                        *anchor.borrow()
234                    })
235                    .unwrap_or(*anchor.borrow());
236                let sel = Some((a.min(byte), a.max(byte)));
237                set_sel(sel);
238            } else {
239                *anchor.borrow_mut() = byte;
240                set_sel(Some((byte, byte)));
241            }
242            *dragging.borrow_mut() = true;
243            let _ = is_multi;
244        }
245    };
246
247    let on_move = {
248        let text = text_for_handlers.clone();
249        let anchor = anchor.clone();
250        let dragging = dragging.clone();
251        let last_rect = last_rect.clone();
252        let set_sel = set_sel.clone();
253        move |ev: PointerEvent| {
254            if !*dragging.borrow() {
255                return;
256            }
257            let r = *last_rect.borrow();
258            if r.w <= 0.0 || r.h <= 0.0 {
259                return;
260            }
261            let font_px = dp_to_px(font_size_dp) * text_scale().0;
262            let lx = (ev.position.x - r.x).max(0.0);
263            let ly = (ev.position.y - r.y).max(0.0);
264            let wrap_w = r.w.max(1.0);
265            let byte = index_for_xy_bytes(&text, font_px, wrap_w, lx, ly);
266            let a = *anchor.borrow();
267            let sel = Some((a.min(byte), a.max(byte)));
268            set_sel(sel);
269            if let Some((s, e)) = sel
270                && e > s
271            {
272                repose_core::clipboard::set_primary_selection(&text[s..e]);
273            }
274        }
275    };
276
277    let on_up = {
278        let text = text_for_handlers.clone();
279        let selection = selection.clone();
280        let dragging = dragging.clone();
281        let set_sel = set_sel.clone();
282        move |_ev: PointerEvent| {
283            *dragging.borrow_mut() = false;
284            let sel = *selection.borrow();
285            if let Some((a, b)) = sel {
286                let s = a.min(b);
287                let e = a.max(b);
288                if e > s {
289                    repose_core::clipboard::set_primary_selection(&text[s..e]);
290                }
291            }
292            set_sel(sel);
293        }
294    };
295
296    // --- highlight painter -------------------------------------------------
297
298    let painter = {
299        let text = text_for_paint.clone();
300        let selection = selection.clone();
301        let last_rect = last_rect.clone();
302        move |scene: &mut Scene, rect: Rect, _alpha: f32| {
303            *last_rect.borrow_mut() = rect;
304
305            let (s, e) = match *selection.borrow() {
306                Some((a, b)) if a != b => {
307                    if a < b {
308                        (a, b)
309                    } else {
310                        (b, a)
311                    }
312                }
313                _ => return,
314            };
315            if e == 0 || e <= s {
316                return;
317            }
318
319            let font_px = dp_to_px(font_size_dp) * text_scale().0;
320            let wrap_w = rect.w.max(1.0);
321            let (sx, sy, sli) = caret_xy_for_byte(&text, font_px, wrap_w, s);
322            let (ex, ey, eli) = caret_xy_for_byte(&text, font_px, wrap_w, e);
323            let th = theme();
324            let brush = Brush::Solid(th.primary.with_alpha(96));
325            let line_h = font_px * 1.2;
326
327            if sli == eli {
328                let x = sx.min(ex);
329                let w = (ex - sx).abs().max(2.0);
330                scene.nodes.push(SceneNode::Rect {
331                    rect: Rect {
332                        x: rect.x + x,
333                        y: rect.y + sy,
334                        w,
335                        h: line_h,
336                    },
337                    brush,
338                    radius: [0.0; 4],
339                });
340            } else {
341                // First partial line
342                scene.nodes.push(SceneNode::Rect {
343                    rect: Rect {
344                        x: rect.x + sx,
345                        y: rect.y + sy,
346                        w: (rect.w - sx).max(2.0),
347                        h: line_h,
348                    },
349                    brush: brush,
350                    radius: [0.0; 4],
351                });
352                // Full middle lines
353                if eli > sli + 1 {
354                    scene.nodes.push(SceneNode::Rect {
355                        rect: Rect {
356                            x: rect.x,
357                            y: rect.y + (sli as f32 + 1.0) * line_h,
358                            w: rect.w,
359                            h: (eli as f32 - sli as f32 - 1.0) * line_h,
360                        },
361                        brush: brush,
362                        radius: [0.0; 4],
363                    });
364                }
365                // Last partial line
366                scene.nodes.push(SceneNode::Rect {
367                    rect: Rect {
368                        x: rect.x,
369                        y: rect.y + ey,
370                        w: ex.max(2.0),
371                        h: line_h,
372                    },
373                    brush,
374                    radius: [0.0; 4],
375                });
376            }
377        }
378    };
379
380    v.modifier = v
381        .modifier
382        .on_pointer_down(on_down)
383        .on_pointer_move(on_move)
384        .on_pointer_up(on_up)
385        .painter(painter)
386        .cursor(CursorIcon::Text)
387        .on_action({
388            let selection = selection.clone();
389            let text = text_for_handlers.clone();
390            move |action| match action {
391                repose_core::shortcuts::Action::Copy => {
392                    let sel = *selection.borrow();
393                    if let Some((a, b)) = sel {
394                        let s = a.min(b);
395                        let e = a.max(b);
396                        if e > s {
397                            repose_core::clipboard::copy_to_clipboard(&text[s..e]);
398                            return true;
399                        }
400                    }
401                    false
402                }
403                repose_core::shortcuts::Action::SelectAll => {
404                    let len = text.len();
405                    *selection.borrow_mut() = Some((0, len));
406                    true
407                }
408                _ => false,
409            }
410        });
411
412    v
413}