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(®ion_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}