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