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