Skip to main content

fission_core/input/
selectable_text.rs

1use super::{ControllerContext, InputController};
2use crate::env::{SelectionGranularity, TextSelectionHandleKind};
3use crate::event::{EditingCommand, GestureEvent, InputEvent, KeyCode, KeyEvent, PointerEvent};
4use crate::selection::{
5    clear_other_regions, document_for_selection_owner, set_region_selection, RegionDocument,
6    SelectionRegionCommand, TextRegionPosition, TextRegionSelection,
7};
8use crate::ui::widgets::context_menu::{text_context_menu_button_id, TextContextMenuAction};
9use crate::ui::widgets::selection_region::{
10    region_runtime_config, selection_region_handle_id, SelectionRegionControls,
11};
12use crate::{TextAffinity, TextPosition};
13use fission_ir::{op::LayoutOp, Op, Semantics, WidgetId};
14use fission_layout::{LayoutNodeGeometry, LayoutPoint};
15use unicode_segmentation::UnicodeSegmentation;
16
17const MULTI_CLICK_INTERVAL_MS: u64 = 500;
18const MULTI_CLICK_SLOP: f32 = 6.0;
19const LONG_PRESS_INTERVAL_MS: u64 = 500;
20
21pub struct SelectableTextController;
22
23#[derive(Clone)]
24struct SelectionTarget {
25    region_id: WidgetId,
26    member_id: WidgetId,
27    semantics: Semantics,
28}
29
30impl InputController for SelectableTextController {
31    fn handle_event(&mut self, ctx: &mut ControllerContext, event: &InputEvent) -> bool {
32        match event {
33            InputEvent::Keyboard(KeyEvent::Down {
34                key_code,
35                modifiers,
36            }) => self.handle_key(ctx, key_code.clone(), *modifiers),
37            InputEvent::Editing(command) => self.handle_editing_command(ctx, command),
38            InputEvent::Gesture(GestureEvent::DoubleTap { point }) => {
39                self.select_gesture(ctx, *point, SelectionGranularity::Word, false)
40            }
41            InputEvent::Gesture(GestureEvent::LongPress { point }) => {
42                self.select_gesture(ctx, *point, SelectionGranularity::Word, true)
43            }
44            InputEvent::Pointer(PointerEvent::Down {
45                point,
46                button,
47                modifiers,
48                kind,
49                ..
50            }) => self.pointer_down(ctx, *point, button, *modifiers, *kind),
51            InputEvent::Pointer(PointerEvent::Move { point, .. }) => self.pointer_move(ctx, *point),
52            InputEvent::Pointer(PointerEvent::Up {
53                point,
54                button,
55                kind,
56                ..
57            }) => self.pointer_up(ctx, *point, button, *kind),
58            InputEvent::Pointer(PointerEvent::Cancel { .. }) => {
59                let Some(region_id) = Self::active_region(ctx) else {
60                    return false;
61                };
62                ctx.selectable_text
63                    .region_mut_or_default(region_id)
64                    .selecting = false;
65                let state = ctx.selectable_text.region_mut_or_default(region_id);
66                state.drag_started = false;
67                state.active_handle = None;
68                state.magnifier_visible = false;
69                true
70            }
71            _ => false,
72        }
73    }
74}
75
76impl SelectableTextController {
77    fn pointer_down(
78        &mut self,
79        ctx: &mut ControllerContext,
80        point: LayoutPoint,
81        button: &crate::event::PointerButton,
82        modifiers: u8,
83        kind: crate::event::PointerKind,
84    ) -> bool {
85        let hit = crate::hit_test::hit_test_with_viewports(
86            ctx.ir,
87            ctx.layout,
88            ctx.scroll,
89            ctx.viewport,
90            point,
91        );
92        if let (Some(owner), Some(hit_node_id)) = (ctx.context_menu.owner, hit) {
93            if let Some(action) = Self::toolbar_action_hit(ctx.ir, owner, hit_node_id) {
94                return self.execute_action(ctx, owner, action);
95            }
96        }
97        if let (Some(owner), Some(hit_node_id)) = (ctx.interaction.focused, hit) {
98            if let Some(handle) = Self::selection_handle_hit(ctx.ir, owner, hit_node_id) {
99                if matches!(button, crate::event::PointerButton::Primary) {
100                    let magnifier_visible = Self::uses_touch_affordances(ctx, owner);
101                    let state = ctx.selectable_text.region_mut_or_default(owner);
102                    state.active_handle = Some(handle);
103                    state.selecting = true;
104                    state.drag_started = true;
105                    state.pointer_kind = kind;
106                    state.pointer_down_at = Some(ctx.current_time);
107                    state.pointer_down_point = Some(point);
108                    state.magnifier_visible = magnifier_visible;
109                    ctx.context_menu.close();
110                    return true;
111                }
112            }
113        }
114        if matches!(button, crate::event::PointerButton::Secondary) {
115            let Some(target) = Self::target_at_point(ctx, point) else {
116                return false;
117            };
118            let Some(document) = document_for_selection_owner(ctx.ir, target.region_id) else {
119                return false;
120            };
121            let existing = Self::selection_for_owner(ctx, target.region_id, &document);
122            let caret = Self::position_for_target(ctx, &target, point);
123            let caret_offset = document.position_offset(caret).unwrap_or(0);
124            let select_clicked_word = existing.is_none_or(|selection| {
125                let base = document.position_offset(selection.base).unwrap_or(0);
126                let extent = document.position_offset(selection.extent).unwrap_or(0);
127                selection.is_collapsed()
128                    || caret_offset < base.min(extent)
129                    || caret_offset > base.max(extent)
130            });
131            if select_clicked_word {
132                let selection =
133                    Self::granular_selection(&document, caret, SelectionGranularity::Word);
134                if set_region_selection(ctx.selectable_text, target.region_id, &document, selection)
135                    .is_err()
136                {
137                    return false;
138                }
139            }
140            ctx.interaction.set_focused(Some(target.region_id));
141            ctx.selectable_text
142                .region_mut_or_default(target.region_id)
143                .pointer_kind = kind;
144            ctx.context_menu.open(target.region_id, point);
145            Self::sync_affordances(ctx, target.region_id, &document, false);
146            return true;
147        }
148        if !matches!(button, crate::event::PointerButton::Primary) {
149            return false;
150        }
151        let Some(target) = Self::target_at_point(ctx, point) else {
152            return false;
153        };
154        let Some(document) = document_for_selection_owner(ctx.ir, target.region_id) else {
155            return false;
156        };
157        let caret = Self::position_for_target(ctx, &target, point);
158        clear_other_regions(ctx.selectable_text, ctx.ir, target.region_id);
159        let controls = Self::controls(ctx, target.region_id);
160        let click_count = if kind == crate::event::PointerKind::Mouse {
161            Self::next_click_count(ctx, target.region_id, point)
162        } else {
163            1
164        };
165        let (selection, granularity) = if Self::has_shift(modifiers) {
166            let base = Self::selection_for_owner(ctx, target.region_id, &document)
167                .map(|selection| selection.base)
168                .unwrap_or(caret);
169            (
170                TextRegionSelection {
171                    base,
172                    extent: caret,
173                    affinity: TextAffinity::Downstream,
174                },
175                SelectionGranularity::Character,
176            )
177        } else if click_count == 2 && controls.word_selection_on_double_click {
178            (
179                Self::granular_selection(&document, caret, SelectionGranularity::Word),
180                SelectionGranularity::Word,
181            )
182        } else if click_count >= 3 && controls.paragraph_selection_on_triple_click {
183            (
184                Self::granular_selection(&document, caret, SelectionGranularity::Paragraph),
185                SelectionGranularity::Paragraph,
186            )
187        } else {
188            (
189                TextRegionSelection::collapsed(caret),
190                SelectionGranularity::Character,
191            )
192        };
193        if set_region_selection(ctx.selectable_text, target.region_id, &document, selection)
194            .is_err()
195        {
196            return false;
197        }
198        let state = ctx.selectable_text.region_mut_or_default(target.region_id);
199        state.selecting = true;
200        state.granularity = granularity;
201        state.pointer_down_at = Some(ctx.current_time);
202        state.pointer_down_point = Some(point);
203        state.pointer_kind = kind;
204        state.drag_started = kind == crate::event::PointerKind::Mouse;
205        ctx.interaction.set_focused(Some(target.region_id));
206        ctx.context_menu.close();
207        Self::sync_affordances(ctx, target.region_id, &document, false);
208        true
209    }
210
211    fn pointer_move(&mut self, ctx: &mut ControllerContext, point: LayoutPoint) -> bool {
212        let Some(region_id) = Self::active_region(ctx) else {
213            return false;
214        };
215        let Some(document) = document_for_selection_owner(ctx.ir, region_id) else {
216            return false;
217        };
218        let controls = Self::controls(ctx, region_id);
219        let (pointer_kind, down_point, drag_started, active_handle) = ctx
220            .selectable_text
221            .region(region_id)
222            .map(|state| {
223                (
224                    state.pointer_kind,
225                    state.pointer_down_point,
226                    state.drag_started,
227                    state.active_handle,
228                )
229            })
230            .unwrap_or_default();
231        if matches!(
232            pointer_kind,
233            crate::event::PointerKind::Touch | crate::event::PointerKind::Stylus
234        ) && !drag_started
235        {
236            if down_point
237                .is_some_and(|origin| Self::distance(origin, point) < controls.touch_slop.max(0.0))
238            {
239                return true;
240            }
241            ctx.selectable_text
242                .region_mut_or_default(region_id)
243                .drag_started = true;
244        }
245        if controls.edge_auto_scroll {
246            Self::edge_auto_scroll(ctx, region_id, point, &controls);
247        }
248        let Some(target) = Self::target_for_active_region(ctx, region_id, point, &document) else {
249            return false;
250        };
251        let caret = Self::position_for_target(ctx, &target, point);
252        let Some(current) = ctx.selectable_text.region_selection(region_id) else {
253            return false;
254        };
255        let granularity = ctx
256            .selectable_text
257            .region(region_id)
258            .map_or(SelectionGranularity::Character, |state| state.granularity);
259        let selection = match active_handle {
260            Some(TextSelectionHandleKind::Caret) => TextRegionSelection::collapsed(caret),
261            Some(TextSelectionHandleKind::Start) => {
262                if document.position_offset(current.base).unwrap_or(0)
263                    <= document.position_offset(current.extent).unwrap_or(0)
264                {
265                    TextRegionSelection {
266                        base: caret,
267                        ..current
268                    }
269                } else {
270                    TextRegionSelection {
271                        extent: caret,
272                        ..current
273                    }
274                }
275            }
276            Some(TextSelectionHandleKind::End) => {
277                if document.position_offset(current.base).unwrap_or(0)
278                    >= document.position_offset(current.extent).unwrap_or(0)
279                {
280                    TextRegionSelection {
281                        base: caret,
282                        ..current
283                    }
284                } else {
285                    TextRegionSelection {
286                        extent: caret,
287                        ..current
288                    }
289                }
290            }
291            None => TextRegionSelection {
292                base: current.base,
293                extent: Self::extent_for_drag(&document, current.base, caret, granularity),
294                affinity: current.affinity,
295            },
296        };
297        if set_region_selection(ctx.selectable_text, region_id, &document, selection).is_err() {
298            return false;
299        }
300        let touch_affordances = controls.platform_style.uses_touch_affordances(pointer_kind);
301        let state = ctx.selectable_text.region_mut_or_default(region_id);
302        state.selecting = true;
303        state.magnifier_visible = touch_affordances;
304        Self::sync_affordances(ctx, region_id, &document, touch_affordances);
305        true
306    }
307
308    fn pointer_up(
309        &mut self,
310        ctx: &mut ControllerContext,
311        point: LayoutPoint,
312        button: &crate::event::PointerButton,
313        kind: crate::event::PointerKind,
314    ) -> bool {
315        if !matches!(button, crate::event::PointerButton::Primary) {
316            return false;
317        }
318        let Some(region_id) = Self::active_region(ctx) else {
319            return false;
320        };
321        let controls = Self::controls(ctx, region_id);
322        let (down_at, down_point, drag_started) = ctx
323            .selectable_text
324            .region(region_id)
325            .map(|state| {
326                (
327                    state.pointer_down_at,
328                    state.pointer_down_point,
329                    state.drag_started,
330                )
331            })
332            .unwrap_or_default();
333        let held = matches!(
334            kind,
335            crate::event::PointerKind::Touch | crate::event::PointerKind::Stylus
336        ) && !drag_started
337            && down_at.is_some_and(|started| {
338                ctx.current_time.saturating_sub(started) >= LONG_PRESS_INTERVAL_MS
339            })
340            && down_point.is_some_and(|origin| {
341                Self::distance(origin, point) <= controls.touch_slop.max(0.0)
342            });
343        if held && controls.word_selection_on_long_press {
344            let _ = self.select_gesture(ctx, point, SelectionGranularity::Word, true);
345        }
346        let document = document_for_selection_owner(ctx.ir, region_id);
347        let show_mobile_toolbar = controls.platform_style.uses_touch_affordances(kind)
348            && document.as_ref().is_some_and(|document| {
349                Self::selection_for_owner(ctx, region_id, document)
350                    .is_some_and(|selection| !selection.is_collapsed())
351            });
352        let state = ctx.selectable_text.region_mut_or_default(region_id);
353        state.selecting = false;
354        state.drag_started = false;
355        state.active_handle = None;
356        state.magnifier_visible = false;
357        if show_mobile_toolbar {
358            ctx.context_menu.open(region_id, point);
359        }
360        if let Some(document) = document.as_ref() {
361            Self::sync_affordances(ctx, region_id, document, false);
362        }
363        true
364    }
365
366    fn select_gesture(
367        &mut self,
368        ctx: &mut ControllerContext,
369        point: LayoutPoint,
370        granularity: SelectionGranularity,
371        show_menu: bool,
372    ) -> bool {
373        let Some(target) = Self::target_at_point(ctx, point) else {
374            return false;
375        };
376        let controls = Self::controls(ctx, target.region_id);
377        if (!show_menu && !controls.word_selection_on_double_click)
378            || (show_menu && !controls.word_selection_on_long_press)
379        {
380            return false;
381        }
382        let Some(document) = document_for_selection_owner(ctx.ir, target.region_id) else {
383            return false;
384        };
385        let caret = Self::position_for_target(ctx, &target, point);
386        let selection = Self::granular_selection(&document, caret, granularity);
387        if set_region_selection(ctx.selectable_text, target.region_id, &document, selection)
388            .is_err()
389        {
390            return false;
391        }
392        let state = ctx.selectable_text.region_mut_or_default(target.region_id);
393        state.selecting = false;
394        state.granularity = granularity;
395        state.pointer_kind = crate::event::PointerKind::Touch;
396        state.drag_started = false;
397        state.active_handle = None;
398        state.magnifier_visible = false;
399        ctx.interaction.set_focused(Some(target.region_id));
400        if show_menu && !selection.is_collapsed() && controls.context_menu.enabled {
401            ctx.context_menu.open(target.region_id, point);
402        }
403        Self::sync_affordances(ctx, target.region_id, &document, false);
404        true
405    }
406
407    fn handle_editing_command(
408        &mut self,
409        ctx: &mut ControllerContext,
410        command: &EditingCommand,
411    ) -> bool {
412        let Some(owner) = ctx.interaction.focused else {
413            return false;
414        };
415        let Some(document) = document_for_selection_owner(ctx.ir, owner) else {
416            return false;
417        };
418        match command {
419            EditingCommand::Copy => {
420                if let Some(selection) = Self::selection_for_owner(ctx, owner, &document) {
421                    if let Some(selected) = document.selected_text(selection) {
422                        if let Some(clipboard) = ctx.clipboard {
423                            clipboard.set_text(&selected);
424                        }
425                    }
426                }
427                true
428            }
429            EditingCommand::SelectAll => {
430                let handled = crate::selection::apply_region_command(
431                    ctx.selectable_text,
432                    ctx.ir,
433                    owner,
434                    SelectionRegionCommand::SelectAll,
435                )
436                .is_ok();
437                if handled {
438                    Self::sync_affordances(ctx, owner, &document, false);
439                }
440                handled
441            }
442            EditingCommand::Cut
443            | EditingCommand::Paste(_)
444            | EditingCommand::Undo
445            | EditingCommand::Redo => false,
446        }
447    }
448
449    fn handle_key(
450        &mut self,
451        ctx: &mut ControllerContext,
452        key_code: KeyCode,
453        modifiers: u8,
454    ) -> bool {
455        let primary = ctx.editing_convention.has_primary_shortcut(modifiers)
456            && !ctx.editing_convention.is_alt_gr(modifiers);
457        if primary {
458            let command = match key_code {
459                KeyCode::Char('c') | KeyCode::Char('C') => Some(EditingCommand::Copy),
460                KeyCode::Char('a') | KeyCode::Char('A') => Some(EditingCommand::SelectAll),
461                _ => None,
462            };
463            if let Some(command) = command {
464                return self.handle_editing_command(ctx, &command);
465            }
466        }
467        match key_code {
468            KeyCode::Left
469            | KeyCode::Right
470            | KeyCode::Up
471            | KeyCode::Down
472            | KeyCode::Home
473            | KeyCode::End => {
474                self.handle_navigation(ctx, key_code, Self::has_shift(modifiers), primary)
475            }
476            _ => false,
477        }
478    }
479
480    fn handle_navigation(
481        &mut self,
482        ctx: &mut ControllerContext,
483        key_code: KeyCode,
484        extend: bool,
485        document_boundary: bool,
486    ) -> bool {
487        let Some(region_id) = ctx.interaction.focused else {
488            return false;
489        };
490        let Some(document) = document_for_selection_owner(ctx.ir, region_id) else {
491            return false;
492        };
493        let Some(current) = Self::selection_for_owner(ctx, region_id, &document) else {
494            return false;
495        };
496        let target = if !extend && !current.is_collapsed() {
497            let base = document.position_offset(current.base).unwrap_or(0);
498            let extent = document.position_offset(current.extent).unwrap_or(0);
499            match key_code {
500                KeyCode::Left | KeyCode::Up | KeyCode::Home => {
501                    if base <= extent {
502                        current.base
503                    } else {
504                        current.extent
505                    }
506                }
507                _ => {
508                    if base >= extent {
509                        current.base
510                    } else {
511                        current.extent
512                    }
513                }
514            }
515        } else {
516            Self::move_position(&document, current.extent, key_code, document_boundary)
517        };
518        let selection = if extend {
519            TextRegionSelection {
520                extent: target,
521                ..current
522            }
523        } else {
524            TextRegionSelection::collapsed(target)
525        };
526        if set_region_selection(ctx.selectable_text, region_id, &document, selection).is_err() {
527            return false;
528        }
529        ctx.selectable_text
530            .region_mut_or_default(region_id)
531            .granularity = SelectionGranularity::Character;
532        Self::sync_affordances(ctx, region_id, &document, false);
533        true
534    }
535
536    fn move_position(
537        document: &RegionDocument,
538        at: TextRegionPosition,
539        key_code: KeyCode,
540        document_boundary: bool,
541    ) -> TextRegionPosition {
542        let Some(index) = document
543            .members
544            .iter()
545            .position(|member| member.node_id == at.node_id)
546        else {
547            return at;
548        };
549        let member = &document.members[index];
550        let offset = at.offset.utf8_offset().min(member.text.len());
551        match key_code {
552            KeyCode::Left => {
553                if offset == 0 {
554                    return index
555                        .checked_sub(1)
556                        .map(|previous| {
557                            let previous = &document.members[previous];
558                            TextRegionPosition::at(
559                                previous.node_id,
560                                TextPosition::at_end(&previous.text),
561                            )
562                        })
563                        .unwrap_or(at);
564                }
565                let previous = member.text[..offset]
566                    .grapheme_indices(true)
567                    .next_back()
568                    .map_or(0, |(start, _)| start);
569                TextRegionPosition::at(member.node_id, TextPosition::floor(&member.text, previous))
570            }
571            KeyCode::Right => {
572                if offset == member.text.len() {
573                    return document
574                        .members
575                        .get(index + 1)
576                        .map(|next| TextRegionPosition::at(next.node_id, TextPosition::START))
577                        .unwrap_or(at);
578                }
579                let next = member.text[offset..]
580                    .graphemes(true)
581                    .next()
582                    .map_or(member.text.len(), |grapheme| offset + grapheme.len());
583                TextRegionPosition::at(member.node_id, TextPosition::floor(&member.text, next))
584            }
585            KeyCode::Up => index
586                .checked_sub(1)
587                .and_then(|previous| document.members.get(previous))
588                .map(|previous| {
589                    TextRegionPosition::at(
590                        previous.node_id,
591                        TextPosition::floor(&previous.text, offset.min(previous.text.len())),
592                    )
593                })
594                .unwrap_or(at),
595            KeyCode::Down => document
596                .members
597                .get(index + 1)
598                .map(|next| {
599                    TextRegionPosition::at(
600                        next.node_id,
601                        TextPosition::floor(&next.text, offset.min(next.text.len())),
602                    )
603                })
604                .unwrap_or(at),
605            KeyCode::Home if document_boundary => document
606                .members
607                .first()
608                .map(|first| TextRegionPosition::at(first.node_id, TextPosition::START))
609                .unwrap_or(at),
610            KeyCode::End if document_boundary => document
611                .members
612                .last()
613                .map(|last| TextRegionPosition::at(last.node_id, TextPosition::at_end(&last.text)))
614                .unwrap_or(at),
615            KeyCode::Home => TextRegionPosition::at(member.node_id, TextPosition::START),
616            KeyCode::End => {
617                TextRegionPosition::at(member.node_id, TextPosition::at_end(&member.text))
618            }
619            _ => at,
620        }
621    }
622
623    fn controls(ctx: &ControllerContext, region_id: WidgetId) -> SelectionRegionControls {
624        region_runtime_config(ctx.ir, region_id)
625            .map(|config| config.controls.clone())
626            .unwrap_or_default()
627    }
628
629    fn uses_touch_affordances(ctx: &ControllerContext, region_id: WidgetId) -> bool {
630        let controls = Self::controls(ctx, region_id);
631        let pointer = ctx
632            .selectable_text
633            .region(region_id)
634            .map_or(crate::event::PointerKind::default(), |state| {
635                state.pointer_kind
636            });
637        controls.platform_style.uses_touch_affordances(pointer)
638    }
639
640    fn sync_affordances(
641        ctx: &mut ControllerContext,
642        region_id: WidgetId,
643        document: &RegionDocument,
644        magnifier_visible: bool,
645    ) {
646        let Some(selection) = Self::selection_for_owner(ctx, region_id, document) else {
647            return;
648        };
649        let base_point = Self::caret_point(ctx, region_id, selection.base);
650        let extent_point = Self::caret_point(ctx, region_id, selection.extent);
651        let base_offset = document.position_offset(selection.base).unwrap_or(0);
652        let extent_offset = document.position_offset(selection.extent).unwrap_or(0);
653        let state = ctx.selectable_text.region_mut_or_default(region_id);
654        state.magnifier_visible = magnifier_visible;
655        state.magnifier_anchor = magnifier_visible.then_some(extent_point).flatten();
656        if selection.is_collapsed() {
657            state.caret_handle = extent_point;
658            state.selection_start_handle = None;
659            state.selection_end_handle = None;
660        } else {
661            state.caret_handle = None;
662            if base_offset <= extent_offset {
663                state.selection_start_handle = base_point;
664                state.selection_end_handle = extent_point;
665            } else {
666                state.selection_start_handle = extent_point;
667                state.selection_end_handle = base_point;
668            }
669        }
670    }
671
672    fn caret_point(
673        ctx: &ControllerContext,
674        region_id: WidgetId,
675        position: TextRegionPosition,
676    ) -> Option<LayoutPoint> {
677        let region_geom = ctx
678            .layout
679            .get_node_geometry(region_id)
680            .or_else(|| Self::layout_geometry(ctx, region_id).map(|(_, geometry)| geometry))?;
681        let (layout_id, member_geom) = Self::layout_geometry(ctx, position.node_id)?;
682        let semantics = crate::selection::selectable_semantics(ctx.ir, position.node_id)?;
683        let text = semantics.value.as_deref().unwrap_or_default();
684        let offset = position.offset.utf8_offset().min(text.len());
685        let paint_id = Self::text_paint_node(ctx.ir, position.node_id).unwrap_or(layout_id);
686        let paint_geom = ctx
687            .layout
688            .get_node_geometry(paint_id)
689            .unwrap_or(member_geom);
690        let (x, y, height) = if let Some(paragraph) = ctx.layout.get_resolved_paragraph(paint_id) {
691            let caret = paragraph.caret(offset, false)?;
692            (caret.position.x, caret.position.y, caret.height.max(1.0))
693        } else {
694            let measurer = ctx.measurer?;
695            let font_size = Self::font_size(ctx.ir, position.node_id).unwrap_or(14.0);
696            let width = (paint_geom.rect.size.width > 0.0).then_some(paint_geom.rect.size.width);
697            let (x, y) = measurer.get_caret_position(text, font_size, width, offset);
698            let height = measurer
699                .get_line_metrics(text, font_size, width)
700                .into_iter()
701                .find(|line| offset >= line.start_index && offset <= line.end_index)
702                .map_or(font_size * 1.25, |line| line.height)
703                .max(1.0);
704            (x, y, height)
705        };
706        Some(LayoutPoint::new(
707            paint_geom.rect.origin.x - region_geom.rect.origin.x + x,
708            paint_geom.rect.origin.y - region_geom.rect.origin.y + y + height,
709        ))
710    }
711
712    fn text_paint_node(ir: &fission_ir::CoreIR, root: WidgetId) -> Option<WidgetId> {
713        let node = ir.nodes.get(&root)?;
714        if matches!(
715            &node.op,
716            Op::Paint(
717                fission_ir::PaintOp::DrawText { .. } | fission_ir::PaintOp::DrawRichText { .. }
718            )
719        ) {
720            return Some(root);
721        }
722        node.children
723            .iter()
724            .find_map(|child| Self::text_paint_node(ir, *child))
725    }
726
727    fn edge_auto_scroll(
728        ctx: &mut ControllerContext,
729        region_id: WidgetId,
730        point: LayoutPoint,
731        controls: &SelectionRegionControls,
732    ) {
733        let mut current = ctx.ir.nodes.get(&region_id).and_then(|node| node.parent);
734        while let Some(node_id) = current {
735            let Some(node) = ctx.ir.nodes.get(&node_id) else {
736                break;
737            };
738            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
739                if let Some(geometry) = ctx.layout.get_node_geometry(node_id) {
740                    let threshold = controls.edge_auto_scroll_threshold.max(1.0).min(
741                        if *direction == fission_ir::FlexDirection::Row {
742                            geometry.rect.size.width * 0.5
743                        } else {
744                            geometry.rect.size.height * 0.5
745                        },
746                    );
747                    let delta = match direction {
748                        fission_ir::FlexDirection::Row
749                            if point.x < geometry.rect.origin.x + threshold =>
750                        {
751                            -controls.edge_auto_scroll_step
752                        }
753                        fission_ir::FlexDirection::Row
754                            if point.x > geometry.rect.right() - threshold =>
755                        {
756                            controls.edge_auto_scroll_step
757                        }
758                        fission_ir::FlexDirection::Column
759                            if point.y < geometry.rect.origin.y + threshold =>
760                        {
761                            -controls.edge_auto_scroll_step
762                        }
763                        fission_ir::FlexDirection::Column
764                            if point.y > geometry.rect.bottom() - threshold =>
765                        {
766                            controls.edge_auto_scroll_step
767                        }
768                        _ => 0.0,
769                    };
770                    if delta != 0.0 {
771                        let viewport = if *direction == fission_ir::FlexDirection::Row {
772                            geometry.rect.size.width
773                        } else {
774                            geometry.rect.size.height
775                        };
776                        let content = if *direction == fission_ir::FlexDirection::Row {
777                            geometry.content_size.width
778                        } else {
779                            geometry.content_size.height
780                        };
781                        let max_offset = (content - viewport).max(0.0);
782                        let offset =
783                            (ctx.scroll.get_offset(node_id) + delta).clamp(0.0, max_offset);
784                        ctx.scroll.set_offset(node_id, offset);
785                    }
786                }
787                return;
788            }
789            current = node.parent;
790        }
791    }
792
793    fn selection_for_owner(
794        ctx: &ControllerContext,
795        owner: WidgetId,
796        document: &RegionDocument,
797    ) -> Option<TextRegionSelection> {
798        ctx.selectable_text.region_selection(owner).or_else(|| {
799            let member = document.members.first()?;
800            if member.node_id != owner || document.members.len() != 1 {
801                return None;
802            }
803            let state = ctx.selectable_text.get(owner)?;
804            Some(TextRegionSelection {
805                base: TextRegionPosition::at(
806                    owner,
807                    TextPosition::floor(&member.text, state.anchor),
808                ),
809                extent: TextRegionPosition::at(
810                    owner,
811                    TextPosition::floor(&member.text, state.caret),
812                ),
813                affinity: TextAffinity::Downstream,
814            })
815        })
816    }
817
818    fn next_click_count(
819        ctx: &mut ControllerContext,
820        region_id: WidgetId,
821        point: LayoutPoint,
822    ) -> u8 {
823        let state = ctx.selectable_text.region_mut_or_default(region_id);
824        let repeated = state
825            .last_click_at
826            .is_some_and(|last| ctx.current_time.saturating_sub(last) <= MULTI_CLICK_INTERVAL_MS)
827            && state
828                .last_click_point
829                .is_some_and(|last| Self::distance(last, point) <= MULTI_CLICK_SLOP);
830        state.click_count = if repeated {
831            (state.click_count % 3) + 1
832        } else {
833            1
834        };
835        state.last_click_at = Some(ctx.current_time);
836        state.last_click_point = Some(point);
837        state.click_count
838    }
839
840    fn position_for_target(
841        ctx: &ControllerContext,
842        target: &SelectionTarget,
843        point: LayoutPoint,
844    ) -> TextRegionPosition {
845        let text = target.semantics.value.as_deref().unwrap_or("");
846        let caret = Self::caret_for_text(ctx, target.member_id, &target.semantics, point);
847        TextRegionPosition::at(target.member_id, TextPosition::floor(text, caret))
848    }
849
850    fn granular_selection(
851        document: &RegionDocument,
852        at: TextRegionPosition,
853        granularity: SelectionGranularity,
854    ) -> TextRegionSelection {
855        let Some(member) = document
856            .members
857            .iter()
858            .find(|member| member.node_id == at.node_id)
859        else {
860            return TextRegionSelection::collapsed(at);
861        };
862        let offset = at.offset.utf8_offset();
863        let (start, end) = match granularity {
864            SelectionGranularity::Character => (offset, offset),
865            SelectionGranularity::Word => Self::word_range(&member.text, offset),
866            SelectionGranularity::Paragraph => Self::paragraph_range(&member.text, offset),
867        };
868        TextRegionSelection {
869            base: TextRegionPosition::at(member.node_id, TextPosition::floor(&member.text, start)),
870            extent: TextRegionPosition::at(member.node_id, TextPosition::floor(&member.text, end)),
871            affinity: TextAffinity::Downstream,
872        }
873    }
874
875    fn extent_for_drag(
876        document: &RegionDocument,
877        base: TextRegionPosition,
878        at: TextRegionPosition,
879        granularity: SelectionGranularity,
880    ) -> TextRegionPosition {
881        if granularity == SelectionGranularity::Character {
882            return at;
883        }
884        let selection = Self::granular_selection(document, at, granularity);
885        if document.position_offset(at).unwrap_or(0) < document.position_offset(base).unwrap_or(0) {
886            selection.base
887        } else {
888            selection.extent
889        }
890    }
891
892    fn word_range(text: &str, offset: usize) -> (usize, usize) {
893        if text.is_empty() {
894            return (0, 0);
895        }
896        let offset = TextPosition::floor(text, offset).utf8_offset();
897        for (start, word) in text.unicode_word_indices() {
898            let end = start + word.len();
899            if (start..end).contains(&offset) || (offset == text.len() && end == offset) {
900                return (start, end);
901            }
902        }
903        let probe = offset.min(text.len().saturating_sub(1));
904        text.grapheme_indices(true)
905            .find_map(|(start, grapheme)| {
906                let end = start + grapheme.len();
907                (probe >= start && probe < end).then_some((start, end))
908            })
909            .unwrap_or((offset, offset))
910    }
911
912    fn paragraph_range(text: &str, offset: usize) -> (usize, usize) {
913        let offset = TextPosition::floor(text, offset).utf8_offset();
914        let start = text[..offset].rfind('\n').map_or(0, |index| index + 1);
915        let end = text[offset..]
916            .find('\n')
917            .map_or(text.len(), |index| offset + index + 1);
918        (start, end)
919    }
920
921    fn target_at_point(ctx: &ControllerContext, point: LayoutPoint) -> Option<SelectionTarget> {
922        let hit = crate::hit_test::hit_test_with_viewports(
923            ctx.ir,
924            ctx.layout,
925            ctx.scroll,
926            ctx.viewport,
927            point,
928        )?;
929        let mut current = Some(hit);
930        let mut selectable: Option<(WidgetId, Semantics)> = None;
931        while let Some(node_id) = current {
932            let node = ctx.ir.nodes.get(&node_id)?;
933            if let Op::Semantics(semantics) = &node.op {
934                if let Some(region) = &semantics.selection_region {
935                    if region.excluded {
936                        return None;
937                    }
938                    if let Some((member_id, member_semantics)) = selectable {
939                        return Some(SelectionTarget {
940                            region_id: node_id,
941                            member_id,
942                            semantics: member_semantics,
943                        });
944                    }
945                    let document = document_for_selection_owner(ctx.ir, node_id)?;
946                    return Self::nearest_target(ctx, node_id, point, &document);
947                }
948                if selectable.is_none() && semantics.selectable_text && !semantics.disabled {
949                    selectable = Some((node_id, semantics.clone()));
950                }
951            }
952            current = node.parent;
953        }
954        selectable.map(|(member_id, semantics)| SelectionTarget {
955            region_id: member_id,
956            member_id,
957            semantics,
958        })
959    }
960
961    fn target_for_active_region(
962        ctx: &ControllerContext,
963        region_id: WidgetId,
964        point: LayoutPoint,
965        document: &RegionDocument,
966    ) -> Option<SelectionTarget> {
967        if let Some(target) = Self::target_at_point(ctx, point) {
968            if target.region_id == region_id {
969                return Some(target);
970            }
971        }
972        Self::nearest_target(ctx, region_id, point, document)
973    }
974
975    fn nearest_target(
976        ctx: &ControllerContext,
977        region_id: WidgetId,
978        point: LayoutPoint,
979        document: &RegionDocument,
980    ) -> Option<SelectionTarget> {
981        document
982            .members
983            .iter()
984            .filter_map(|member| {
985                let semantics = crate::selection::selectable_semantics(ctx.ir, member.node_id)?;
986                let (_, geometry) = Self::layout_geometry(ctx, member.node_id)?;
987                let rect = geometry.rect;
988                let max_x = rect.origin.x + rect.size.width;
989                let max_y = rect.origin.y + rect.size.height;
990                let dx = if point.x < rect.origin.x {
991                    rect.origin.x - point.x
992                } else if point.x > max_x {
993                    point.x - max_x
994                } else {
995                    0.0
996                };
997                let dy = if point.y < rect.origin.y {
998                    rect.origin.y - point.y
999                } else if point.y > max_y {
1000                    point.y - max_y
1001                } else {
1002                    0.0
1003                };
1004                Some((dx * dx + dy * dy, member.node_id, semantics.clone()))
1005            })
1006            .min_by(|left, right| left.0.total_cmp(&right.0))
1007            .map(|(_, member_id, semantics)| SelectionTarget {
1008                region_id,
1009                member_id,
1010                semantics,
1011            })
1012    }
1013
1014    fn toolbar_action_hit(
1015        ir: &fission_ir::CoreIR,
1016        owner: WidgetId,
1017        hit_node_id: WidgetId,
1018    ) -> Option<TextContextMenuAction> {
1019        [
1020            TextContextMenuAction::Copy,
1021            TextContextMenuAction::Cut,
1022            TextContextMenuAction::Paste,
1023            TextContextMenuAction::SelectAll,
1024        ]
1025        .into_iter()
1026        .find(|action| {
1027            Self::node_or_ancestor_matches(
1028                ir,
1029                hit_node_id,
1030                text_context_menu_button_id(owner, *action),
1031            )
1032        })
1033    }
1034
1035    fn selection_handle_hit(
1036        ir: &fission_ir::CoreIR,
1037        owner: WidgetId,
1038        hit_node_id: WidgetId,
1039    ) -> Option<TextSelectionHandleKind> {
1040        [
1041            TextSelectionHandleKind::Caret,
1042            TextSelectionHandleKind::Start,
1043            TextSelectionHandleKind::End,
1044        ]
1045        .into_iter()
1046        .find(|kind| {
1047            Self::node_or_ancestor_matches(
1048                ir,
1049                hit_node_id,
1050                selection_region_handle_id(owner, *kind),
1051            )
1052        })
1053    }
1054
1055    fn execute_action(
1056        &mut self,
1057        ctx: &mut ControllerContext,
1058        owner: WidgetId,
1059        action: TextContextMenuAction,
1060    ) -> bool {
1061        let command = match action {
1062            TextContextMenuAction::Copy => Some(EditingCommand::Copy),
1063            TextContextMenuAction::SelectAll => Some(EditingCommand::SelectAll),
1064            TextContextMenuAction::Cut | TextContextMenuAction::Paste => None,
1065        };
1066        let handled = command.is_none_or(|command| {
1067            let old_focus = ctx.interaction.focused;
1068            ctx.interaction.set_focused(Some(owner));
1069            let handled = self.handle_editing_command(ctx, &command);
1070            ctx.interaction.set_focused(old_focus);
1071            handled
1072        });
1073        ctx.context_menu.close();
1074        handled
1075    }
1076
1077    fn caret_for_text(
1078        ctx: &ControllerContext,
1079        owner: WidgetId,
1080        semantics: &Semantics,
1081        point: LayoutPoint,
1082    ) -> usize {
1083        let value = semantics.value.as_deref().unwrap_or("");
1084        let Some((layout_id, geom)) = Self::layout_geometry(ctx, owner) else {
1085            return 0;
1086        };
1087        let local = Self::local_point(ctx, layout_id, geom, point);
1088        if let Some((paragraph_id, paragraph_geom, paragraph)) =
1089            Self::resolved_paragraph_geometry(ctx, owner, value.len())
1090        {
1091            let local = Self::local_point(ctx, paragraph_id, paragraph_geom, point);
1092            return TextPosition::floor(value, paragraph.hit_test(local).min(value.len()))
1093                .utf8_offset();
1094        }
1095        let Some(measurer) = ctx.measurer else {
1096            return 0;
1097        };
1098        let width = (geom.rect.size.width > 0.0).then_some(geom.rect.size.width);
1099        let caret = if let Some(runs) = Self::rich_runs(ctx.ir, owner) {
1100            measurer.hit_test_rich(&runs, width, local.x, local.y)
1101        } else {
1102            measurer.hit_test(
1103                value,
1104                Self::font_size(ctx.ir, owner).unwrap_or(14.0),
1105                width,
1106                local.x,
1107                local.y,
1108            )
1109        };
1110        TextPosition::floor(value, caret.min(value.len())).utf8_offset()
1111    }
1112
1113    fn resolved_paragraph_geometry<'a>(
1114        ctx: &'a ControllerContext,
1115        owner: WidgetId,
1116        expected_len: usize,
1117    ) -> Option<(
1118        WidgetId,
1119        &'a LayoutNodeGeometry,
1120        &'a fission_layout::ResolvedParagraphLayout,
1121    )> {
1122        fn walk<'a>(
1123            ctx: &'a ControllerContext,
1124            node_id: WidgetId,
1125            expected_len: usize,
1126        ) -> Option<(
1127            WidgetId,
1128            &'a LayoutNodeGeometry,
1129            &'a fission_layout::ResolvedParagraphLayout,
1130        )> {
1131            if let (Some(geometry), Some(paragraph)) = (
1132                ctx.layout.get_node_geometry(node_id),
1133                ctx.layout.get_resolved_paragraph(node_id),
1134            ) {
1135                let text_len = paragraph
1136                    .caret_stops
1137                    .iter()
1138                    .map(|stop| stop.index)
1139                    .max()
1140                    .unwrap_or(0);
1141                if text_len == expected_len {
1142                    return Some((node_id, geometry, paragraph));
1143                }
1144            }
1145            for child in &ctx.ir.nodes.get(&node_id)?.children {
1146                if let Some(found) = walk(ctx, *child, expected_len) {
1147                    return Some(found);
1148                }
1149            }
1150            None
1151        }
1152        walk(ctx, owner, expected_len)
1153    }
1154
1155    fn layout_geometry<'a>(
1156        ctx: &'a ControllerContext,
1157        owner: WidgetId,
1158    ) -> Option<(WidgetId, &'a LayoutNodeGeometry)> {
1159        fn walk<'a>(
1160            ctx: &'a ControllerContext,
1161            node_id: WidgetId,
1162        ) -> Option<(WidgetId, &'a LayoutNodeGeometry)> {
1163            if let Some(geom) = ctx.layout.get_node_geometry(node_id) {
1164                return Some((node_id, geom));
1165            }
1166            for child in &ctx.ir.nodes.get(&node_id)?.children {
1167                if let Some(found) = walk(ctx, *child) {
1168                    return Some(found);
1169                }
1170            }
1171            None
1172        }
1173        walk(ctx, owner)
1174    }
1175
1176    fn local_point(
1177        ctx: &ControllerContext,
1178        node_id: WidgetId,
1179        geom: &LayoutNodeGeometry,
1180        point: LayoutPoint,
1181    ) -> LayoutPoint {
1182        let mut scroll_x = 0.0;
1183        let mut scroll_y = 0.0;
1184        let mut walk = ctx.ir.nodes.get(&node_id).and_then(|node| node.parent);
1185        while let Some(parent_id) = walk {
1186            let Some(parent) = ctx.ir.nodes.get(&parent_id) else {
1187                break;
1188            };
1189            if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &parent.op {
1190                let offset = ctx.scroll.get_offset(parent_id);
1191                match direction {
1192                    fission_ir::FlexDirection::Row => scroll_x += offset,
1193                    fission_ir::FlexDirection::Column => scroll_y += offset,
1194                }
1195            }
1196            walk = parent.parent;
1197        }
1198        LayoutPoint::new(
1199            point.x - geom.rect.origin.x + scroll_x,
1200            point.y - geom.rect.origin.y + scroll_y,
1201        )
1202    }
1203
1204    fn rich_runs(ir: &fission_ir::CoreIR, owner: WidgetId) -> Option<Vec<fission_ir::op::TextRun>> {
1205        fn walk(
1206            ir: &fission_ir::CoreIR,
1207            node_id: WidgetId,
1208        ) -> Option<Vec<fission_ir::op::TextRun>> {
1209            let node = ir.nodes.get(&node_id)?;
1210            match &node.op {
1211                Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) if !runs.is_empty() => {
1212                    Some(runs.clone())
1213                }
1214                _ => node.children.iter().find_map(|child| walk(ir, *child)),
1215            }
1216        }
1217        walk(ir, owner)
1218    }
1219
1220    fn font_size(ir: &fission_ir::CoreIR, owner: WidgetId) -> Option<f32> {
1221        fn walk(ir: &fission_ir::CoreIR, node_id: WidgetId) -> Option<f32> {
1222            let node = ir.nodes.get(&node_id)?;
1223            match &node.op {
1224                Op::Paint(fission_ir::PaintOp::DrawText { size, .. }) => Some(*size),
1225                Op::Paint(fission_ir::PaintOp::DrawRichText { runs, .. }) => {
1226                    runs.first().map(|run| run.style.font_size)
1227                }
1228                _ => node.children.iter().find_map(|child| walk(ir, *child)),
1229            }
1230        }
1231        walk(ir, owner)
1232    }
1233
1234    fn active_region(ctx: &ControllerContext) -> Option<WidgetId> {
1235        ctx.selectable_text
1236            .regions
1237            .iter()
1238            .find_map(|(id, state)| state.selecting.then_some(*id))
1239    }
1240
1241    fn node_or_ancestor_matches(
1242        ir: &fission_ir::CoreIR,
1243        node_id: WidgetId,
1244        expected: WidgetId,
1245    ) -> bool {
1246        let mut current = Some(node_id);
1247        while let Some(id) = current {
1248            if id == expected {
1249                return true;
1250            }
1251            current = ir.nodes.get(&id).and_then(|node| node.parent);
1252        }
1253        false
1254    }
1255
1256    fn distance(left: LayoutPoint, right: LayoutPoint) -> f32 {
1257        let dx = left.x - right.x;
1258        let dy = left.y - right.y;
1259        (dx * dx + dy * dy).sqrt()
1260    }
1261
1262    fn has_shift(modifiers: u8) -> bool {
1263        (modifiers & crate::event::MOD_SHIFT) != 0
1264    }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269    use super::SelectableTextController;
1270
1271    #[test]
1272    fn word_selection_handles_unicode_and_whitespace() {
1273        assert_eq!(
1274            SelectableTextController::word_range("hello café", 8),
1275            (6, 11)
1276        );
1277        assert_eq!(SelectableTextController::word_range("one two", 3), (3, 4));
1278    }
1279
1280    #[test]
1281    fn paragraph_selection_includes_the_trailing_separator() {
1282        assert_eq!(
1283            SelectableTextController::paragraph_range("one\ntwo\nthree", 5),
1284            (4, 8)
1285        );
1286        assert_eq!(
1287            SelectableTextController::paragraph_range("one\ntwo\nthree", 10),
1288            (8, 13)
1289        );
1290    }
1291}