Skip to main content

fission_core/ui/widgets/
selection_region.rs

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/// Platform presentation requested for selection handles, menus, and magnifiers.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
22pub enum SelectionPlatformStyle {
23    /// Resolve from the active target and design system.
24    #[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/// Interaction and platform-presentation policy for a coordinated selection.
47#[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    /// Minimum touch/stylus movement before a drag changes the selection.
56    pub touch_slop: f32,
57    /// Whether selection drags near a scroll viewport edge advance that viewport.
58    pub edge_auto_scroll: bool,
59    /// Distance from a viewport edge that activates automatic scrolling.
60    pub edge_auto_scroll_threshold: f32,
61    /// Logical pixels advanced for each pointer-move near an edge.
62    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/// Coordinates read-only selection across all selectable text descendants.
86///
87/// A `Text` or `RichText` with `selectable(true)` remains independently
88/// selectable when no explicit region surrounds it. Wrap several such widgets
89/// in a `SelectionRegion` to make dragging, copying, and select-all operate on
90/// their retained document order as one selection.
91#[derive(Clone, Debug, Serialize, Deserialize)]
92pub struct SelectionRegion {
93    pub id: Option<WidgetId>,
94    pub child: Widget,
95    /// Text inserted between descendants when copying the coordinated value.
96    pub separator: String,
97    pub controls: SelectionRegionControls,
98    /// An excluded region blocks selection inherited from an ancestor region.
99    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    /// Excludes an otherwise selectable subtree from its nearest ancestor region.
114    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(&region_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}