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