1use crate::env::TextSelectionHandleKind;
2use crate::internal::InternalLower;
3use crate::lowering::{InternalIrBuilder, InternalLoweringCx};
4use crate::selection::{selectable_members_in_subtree, SelectionRegionController};
5use crate::ui::widgets::context_menu::{
6 anchor_to_local, text_context_menu_item_widget, text_context_menu_overlay_widget,
7 TextContextMenuAction, TextContextMenuConfig,
8};
9use crate::ui::widgets::text_input::{TextMagnifierConfiguration, TextSelectionControls};
10use crate::ui::{
11 Button, ButtonContentAlign, ButtonVariant, Container, Positioned, Row, Spacer, Text, Widget,
12};
13use fission_ir::{
14 op::{Color, Fill},
15 LayoutOp, Op, Role, SelectionRegionSemantics, Semantics, WidgetId,
16};
17use serde::{Deserialize, Serialize};
18use unicode_segmentation::UnicodeSegmentation;
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
22pub enum SelectionPlatformStyle {
23 #[default]
25 Adaptive,
26 Desktop,
27 Mobile,
28}
29
30impl SelectionPlatformStyle {
31 pub(crate) fn uses_touch_affordances(self, pointer: crate::event::PointerKind) -> bool {
32 match self {
33 Self::Desktop => false,
34 Self::Mobile => true,
35 Self::Adaptive => {
36 cfg!(any(target_os = "android", target_os = "ios"))
37 || matches!(
38 pointer,
39 crate::event::PointerKind::Touch | crate::event::PointerKind::Stylus
40 )
41 }
42 }
43 }
44}
45
46#[derive(Clone, Debug, Serialize, Deserialize)]
48#[serde(default)]
49pub struct SelectionRegionControls {
50 pub word_selection_on_double_click: bool,
51 pub paragraph_selection_on_triple_click: bool,
52 pub word_selection_on_long_press: bool,
53 pub platform_style: SelectionPlatformStyle,
54 pub context_menu: TextContextMenuConfig,
55 pub touch_slop: f32,
57 pub edge_auto_scroll: bool,
59 pub edge_auto_scroll_threshold: f32,
61 pub edge_auto_scroll_step: f32,
63 pub selection_controls: TextSelectionControls,
64 pub magnifier_configuration: TextMagnifierConfiguration,
65}
66
67impl Default for SelectionRegionControls {
68 fn default() -> Self {
69 Self {
70 word_selection_on_double_click: true,
71 paragraph_selection_on_triple_click: true,
72 word_selection_on_long_press: true,
73 platform_style: SelectionPlatformStyle::Adaptive,
74 context_menu: TextContextMenuConfig::read_only(),
75 touch_slop: 8.0,
76 edge_auto_scroll: true,
77 edge_auto_scroll_threshold: 28.0,
78 edge_auto_scroll_step: 18.0,
79 selection_controls: TextSelectionControls::default(),
80 magnifier_configuration: TextMagnifierConfiguration::default(),
81 }
82 }
83}
84
85#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct SelectionRegion {
93 pub id: Option<WidgetId>,
94 pub child: Widget,
95 pub separator: String,
97 pub controls: SelectionRegionControls,
98 pub excluded: bool,
100}
101
102impl SelectionRegion {
103 pub fn new(child: impl Into<Widget>) -> Self {
104 Self {
105 id: None,
106 child: child.into(),
107 separator: "\n".into(),
108 controls: SelectionRegionControls::default(),
109 excluded: false,
110 }
111 }
112
113 pub fn exclude(child: impl Into<Widget>) -> Self {
115 Self {
116 excluded: true,
117 controls: SelectionRegionControls {
118 context_menu: TextContextMenuConfig::disabled(),
119 ..SelectionRegionControls::default()
120 },
121 ..Self::new(child)
122 }
123 }
124
125 pub fn controller(mut self, controller: SelectionRegionController) -> Self {
126 self.id = Some(controller.id());
127 self
128 }
129
130 pub fn separator(mut self, separator: impl Into<String>) -> Self {
131 self.separator = separator.into();
132 self
133 }
134
135 pub fn controls(mut self, controls: SelectionRegionControls) -> Self {
136 self.controls = controls;
137 self
138 }
139}
140
141#[derive(Clone, Debug)]
142pub(crate) struct SelectionRegionRuntimeConfig {
143 pub controls: SelectionRegionControls,
144}
145
146pub(crate) fn region_runtime_config(
147 ir: &fission_ir::CoreIR,
148 region_id: WidgetId,
149) -> Option<&SelectionRegionRuntimeConfig> {
150 ir.custom_render_objects
151 .get(®ion_id)?
152 .downcast_ref::<SelectionRegionRuntimeConfig>()
153}
154
155pub(crate) fn selection_region_handle_id(
156 region_id: WidgetId,
157 kind: TextSelectionHandleKind,
158) -> WidgetId {
159 let suffix = match kind {
160 TextSelectionHandleKind::Caret => 0,
161 TextSelectionHandleKind::Start => 1,
162 TextSelectionHandleKind::End => 2,
163 };
164 WidgetId::derived(region_id.as_u128(), &[0x5E1E, suffix])
165}
166
167pub(crate) fn selection_region_magnifier_id(region_id: WidgetId) -> WidgetId {
168 WidgetId::derived(region_id.as_u128(), &[0x5E1E, 3])
169}
170
171pub(crate) fn selection_region_handle_position_id(
172 region_id: WidgetId,
173 kind: TextSelectionHandleKind,
174) -> WidgetId {
175 let suffix = match kind {
176 TextSelectionHandleKind::Caret => 10,
177 TextSelectionHandleKind::Start => 11,
178 TextSelectionHandleKind::End => 12,
179 };
180 WidgetId::derived(region_id.as_u128(), &[0x5E1E, suffix])
181}
182
183fn build_selection_handle(
184 cx: &mut InternalLoweringCx,
185 region_id: WidgetId,
186 controls: &TextSelectionControls,
187 kind: TextSelectionHandleKind,
188 point: fission_layout::LayoutPoint,
189) -> WidgetId {
190 let diameter = controls.handle_radius * 2.0;
191 let handle: Widget = Button {
192 id: Some(selection_region_handle_id(region_id, kind).into()),
193 semantics: Some(Semantics {
194 role: Role::Generic,
195 draggable: true,
196 ..Semantics::default()
197 }),
198 child: Some(
199 Container::new(Spacer {
200 width: Some(diameter),
201 height: Some(diameter),
202 ..Default::default()
203 })
204 .bg_fill(Fill::Solid(controls.handle_fill))
205 .border(
206 controls.handle_stroke.unwrap_or(Color {
207 r: 0,
208 g: 0,
209 b: 0,
210 a: 0,
211 }),
212 controls.handle_stroke_width,
213 )
214 .border_radius(controls.handle_radius)
215 .into(),
216 ),
217 width: Some(diameter),
218 height: Some(diameter),
219 padding: Some([0.0; 4]),
220 content_align: ButtonContentAlign::Center,
221 variant: ButtonVariant::Ghost,
222 ..Default::default()
223 }
224 .into();
225 Positioned {
226 id: Some(selection_region_handle_position_id(region_id, kind)),
227 left: Some((point.x - controls.handle_radius).max(0.0)),
228 top: Some((point.y - controls.handle_radius).max(0.0)),
229 width: Some(diameter),
230 height: Some(diameter),
231 child: Some(handle),
232 ..Default::default()
233 }
234 .lower(cx)
235}
236
237fn magnifier_snippet(text: &str, offset: usize) -> String {
238 let graphemes = text.grapheme_indices(true).collect::<Vec<_>>();
239 if graphemes.is_empty() {
240 return String::new();
241 }
242 let center = graphemes
243 .iter()
244 .position(|(index, _)| *index >= offset.min(text.len()))
245 .unwrap_or(graphemes.len().saturating_sub(1));
246 graphemes[center.saturating_sub(4)..(center + 5).min(graphemes.len())]
247 .iter()
248 .map(|(_, grapheme)| *grapheme)
249 .collect()
250}
251
252fn build_magnifier(
253 cx: &mut InternalLoweringCx,
254 region_id: WidgetId,
255 config: &TextMagnifierConfiguration,
256 anchor: fission_layout::LayoutPoint,
257 text: &str,
258 offset: usize,
259) -> WidgetId {
260 let tokens = &cx.env.theme.tokens;
261 let preview = Text::new(magnifier_snippet(text, offset))
262 .size(tokens.typography.body_medium_size * config.scale)
263 .color(tokens.colors.text_primary);
264 let magnifier: Widget = Container::new(preview)
265 .width(config.diameter)
266 .height(config.diameter)
267 .bg_fill(Fill::Solid(tokens.colors.surface))
268 .border(
269 config.border_color.unwrap_or(tokens.colors.border),
270 config.border_width,
271 )
272 .border_radius(config.border_radius)
273 .padding_all(8.0)
274 .into();
275 Positioned {
276 id: Some(selection_region_magnifier_id(region_id)),
277 left: Some((anchor.x - config.diameter * 0.5).max(0.0)),
278 top: Some((anchor.y - config.diameter - 18.0).max(0.0)),
279 width: Some(config.diameter),
280 height: Some(config.diameter),
281 child: Some(magnifier),
282 ..Default::default()
283 }
284 .lower(cx)
285}
286
287fn build_mobile_toolbar(
288 config: &TextContextMenuConfig,
289 owner: WidgetId,
290 anchor: fission_layout::LayoutPoint,
291 selection_present: bool,
292 document_present: bool,
293) -> Widget {
294 let actions = config
295 .actions
296 .iter()
297 .copied()
298 .map(|action| {
299 let enabled = match action {
300 TextContextMenuAction::Copy => selection_present,
301 TextContextMenuAction::SelectAll => document_present,
302 TextContextMenuAction::Cut | TextContextMenuAction::Paste => false,
303 };
304 text_context_menu_item_widget(owner, action, enabled)
305 })
306 .collect();
307 let background = config.menu.background.unwrap_or(fission_ir::op::Color {
308 r: 255,
309 g: 255,
310 b: 255,
311 a: 248,
312 });
313 let border = config.menu.border_color.unwrap_or(fission_ir::op::Color {
314 r: 226,
315 g: 232,
316 b: 240,
317 a: 255,
318 });
319 Positioned {
320 left: Some(anchor.x.max(0.0)),
321 top: Some((anchor.y - 48.0).max(0.0)),
322 child: Some(
323 Container::new(Row {
324 children: actions,
325 gap: Some(config.menu.gap),
326 ..Default::default()
327 })
328 .padding(config.menu.padding)
329 .bg(background)
330 .border(border, config.menu.border_width)
331 .border_radius(config.menu.border_radius)
332 .shadow(config.menu.shadow.unwrap_or(fission_ir::op::BoxShadow {
333 spread_radius: 0.0,
334 inset: false,
335 offset: (0.0, 8.0),
336 blur_radius: 24.0,
337 color: fission_ir::op::Color {
338 r: 15,
339 g: 23,
340 b: 42,
341 a: 38,
342 },
343 }))
344 .into(),
345 ),
346 ..Default::default()
347 }
348 .into()
349}
350
351pub(crate) fn wrap_implicit_selection_affordances(
352 cx: &mut InternalLoweringCx<'_>,
353 owner: WidgetId,
354 visual_id: WidgetId,
355 context_menu: &TextContextMenuConfig,
356 selection: Option<(usize, usize)>,
357 text: &str,
358) -> WidgetId {
359 let controls = SelectionRegionControls {
360 context_menu: context_menu.clone(),
361 ..SelectionRegionControls::default()
362 };
363 let state = cx
364 .runtime_state
365 .selectable_text
366 .region(owner)
367 .cloned()
368 .unwrap_or_default();
369 let touch = controls
370 .platform_style
371 .uses_touch_affordances(state.pointer_kind);
372 let mut overlays = Vec::new();
373 if touch && controls.selection_controls.enabled {
374 if selection.is_some() {
375 for (kind, point) in [
376 (TextSelectionHandleKind::Start, state.selection_start_handle),
377 (TextSelectionHandleKind::End, state.selection_end_handle),
378 ] {
379 if let Some(point) = point {
380 overlays.push(build_selection_handle(
381 cx,
382 owner,
383 &controls.selection_controls,
384 kind,
385 point,
386 ));
387 }
388 }
389 } else if controls.selection_controls.show_collapsed_handle {
390 if let Some(point) = state.caret_handle {
391 overlays.push(build_selection_handle(
392 cx,
393 owner,
394 &controls.selection_controls,
395 TextSelectionHandleKind::Caret,
396 point,
397 ));
398 }
399 }
400 }
401 if touch && controls.magnifier_configuration.enabled && state.magnifier_visible {
402 if let (Some(anchor), Some(region_selection)) = (
403 state.magnifier_anchor,
404 cx.runtime_state.selectable_text.region_selection(owner),
405 ) {
406 overlays.push(build_magnifier(
407 cx,
408 owner,
409 &controls.magnifier_configuration,
410 anchor,
411 text,
412 region_selection.extent.offset.utf8_offset(),
413 ));
414 }
415 }
416 if context_menu.enabled && cx.runtime_state.context_menu.owner == Some(owner) {
417 let anchor = cx
418 .runtime_state
419 .context_menu
420 .anchor
421 .map(|point| anchor_to_local(cx, owner, point))
422 .unwrap_or_default();
423 let menu = if touch {
424 build_mobile_toolbar(
425 context_menu,
426 owner,
427 anchor,
428 selection.is_some(),
429 !text.is_empty(),
430 )
431 } else {
432 text_context_menu_overlay_widget(context_menu, owner, anchor, |action| match action {
433 TextContextMenuAction::Copy => selection.is_some(),
434 TextContextMenuAction::SelectAll => !text.is_empty(),
435 TextContextMenuAction::Cut | TextContextMenuAction::Paste => false,
436 })
437 };
438 overlays.push(menu.lower(cx));
439 }
440 if overlays.is_empty() {
441 visual_id
442 } else {
443 let mut stack = InternalIrBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::ZStack));
444 stack.add_child(visual_id);
445 for overlay in overlays {
446 stack.add_child(overlay);
447 }
448 stack.build(cx)
449 }
450}
451
452impl InternalLower for SelectionRegion {
453 fn lower(&self, cx: &mut InternalLoweringCx) -> WidgetId {
454 let owner = self.id.unwrap_or_else(|| cx.next_node_id());
455 cx.push_scope(owner);
456 let child_id = self.child.lower(cx);
457 let member_ids = selectable_members_in_subtree(&cx.ir, child_id);
458
459 let document = member_ids
460 .iter()
461 .filter_map(|id| {
462 cx.ir.nodes.get(id).and_then(|node| match &node.op {
463 Op::Semantics(semantics) => semantics.value.as_deref(),
464 _ => None,
465 })
466 })
467 .collect::<Vec<_>>()
468 .join(&self.separator);
469 let runtime_selection = cx.runtime_state.selectable_text.region_selection(owner);
470 let accessibility_selection = runtime_selection.and_then(|selection| {
471 let mut offset = 0;
472 let mut base = None;
473 let mut extent = None;
474 for (index, member_id) in member_ids.iter().enumerate() {
475 if index > 0 {
476 offset += self.separator.len();
477 }
478 let value_len = cx
479 .ir
480 .nodes
481 .get(member_id)
482 .and_then(|node| match &node.op {
483 Op::Semantics(semantics) => semantics.value.as_ref(),
484 _ => None,
485 })
486 .map_or(0, String::len);
487 if selection.base.node_id == *member_id {
488 base = Some(offset + selection.base.offset.utf8_offset().min(value_len));
489 }
490 if selection.extent.node_id == *member_id {
491 extent = Some(offset + selection.extent.offset.utf8_offset().min(value_len));
492 }
493 offset += value_len;
494 }
495 Some((base?, extent?))
496 });
497
498 let selection_present =
499 runtime_selection.is_some_and(|selection| !selection.is_collapsed());
500 let region_state = cx
501 .runtime_state
502 .selectable_text
503 .region(owner)
504 .cloned()
505 .unwrap_or_default();
506 let touch_affordances = self
507 .controls
508 .platform_style
509 .uses_touch_affordances(region_state.pointer_kind);
510 let mut overlays = Vec::new();
511 if !self.excluded && touch_affordances && self.controls.selection_controls.enabled {
512 if selection_present {
513 if let Some(point) = region_state.selection_start_handle {
514 overlays.push(build_selection_handle(
515 cx,
516 owner,
517 &self.controls.selection_controls,
518 TextSelectionHandleKind::Start,
519 point,
520 ));
521 }
522 if let Some(point) = region_state.selection_end_handle {
523 overlays.push(build_selection_handle(
524 cx,
525 owner,
526 &self.controls.selection_controls,
527 TextSelectionHandleKind::End,
528 point,
529 ));
530 }
531 } else if self.controls.selection_controls.show_collapsed_handle {
532 if let Some(point) = region_state.caret_handle {
533 overlays.push(build_selection_handle(
534 cx,
535 owner,
536 &self.controls.selection_controls,
537 TextSelectionHandleKind::Caret,
538 point,
539 ));
540 }
541 }
542 }
543 if !self.excluded
544 && touch_affordances
545 && self.controls.magnifier_configuration.enabled
546 && region_state.magnifier_visible
547 {
548 if let (Some(anchor), Some(selection)) =
549 (region_state.magnifier_anchor, runtime_selection)
550 {
551 let member_text = cx
552 .ir
553 .nodes
554 .get(&selection.extent.node_id)
555 .and_then(|node| match &node.op {
556 Op::Semantics(semantics) => semantics.value.as_deref(),
557 _ => None,
558 })
559 .unwrap_or_default()
560 .to_owned();
561 overlays.push(build_magnifier(
562 cx,
563 owner,
564 &self.controls.magnifier_configuration,
565 anchor,
566 &member_text,
567 selection.extent.offset.utf8_offset(),
568 ));
569 }
570 }
571 if !self.excluded
572 && self.controls.context_menu.enabled
573 && cx.runtime_state.context_menu.owner == Some(owner)
574 {
575 let anchor = cx
576 .runtime_state
577 .context_menu
578 .anchor
579 .map(|point| anchor_to_local(cx, owner, point))
580 .unwrap_or_default();
581 let menu = if touch_affordances {
582 build_mobile_toolbar(
583 &self.controls.context_menu,
584 owner,
585 anchor,
586 selection_present,
587 !document.is_empty(),
588 )
589 } else {
590 text_context_menu_overlay_widget(
591 &self.controls.context_menu,
592 owner,
593 anchor,
594 |action| match action {
595 TextContextMenuAction::Copy => selection_present,
596 TextContextMenuAction::SelectAll => !document.is_empty(),
597 TextContextMenuAction::Cut | TextContextMenuAction::Paste => false,
598 },
599 )
600 };
601 overlays.push(menu.lower(cx));
602 }
603 let visual_id = if overlays.is_empty() {
604 child_id
605 } else {
606 let mut stack = InternalIrBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::ZStack));
607 stack.add_child(child_id);
608 for overlay in overlays {
609 stack.add_child(overlay);
610 }
611 stack.build(cx)
612 };
613
614 let semantics = Semantics {
615 role: if self.excluded {
616 Role::Generic
617 } else {
618 Role::Text
619 },
620 value: (!self.excluded).then_some(document),
621 focusable: !self.excluded && !member_ids.is_empty(),
622 read_only: !self.excluded,
623 multiline: member_ids.len() > 1,
624 text_selection: accessibility_selection,
625 context_menu: !self.excluded && self.controls.context_menu.enabled,
626 selection_region: Some(SelectionRegionSemantics {
627 excluded: self.excluded,
628 separator: self.separator.clone(),
629 }),
630 ..Semantics::default()
631 };
632 let mut builder = InternalIrBuilder::new(owner, Op::Semantics(semantics));
633 builder.add_child(visual_id);
634 let owner = builder.build(cx);
635 cx.ir.custom_render_objects.insert(
636 owner,
637 std::sync::Arc::new(SelectionRegionRuntimeConfig {
638 controls: self.controls.clone(),
639 }),
640 );
641 cx.pop_scope();
642 owner
643 }
644}