Skip to main content

cranpose_ui/widgets/
selection_container.rs

1//! [`SelectionContainer`] and [`DisableSelection`]: read-only text a person
2//! can select and copy.
3
4use std::{cell::RefCell, rc::Rc};
5
6use cranpose_core::{
7    CompositionLocal, CompositionLocalProvider, DisposableEffect, remember, rememberMutableStateOf,
8    with_current_composer,
9};
10use cranpose_foundation::{PointerButton, modifier_element};
11
12use crate::{
13    PointerEventKind, PointerInputScope, composable,
14    modifier::{Brush, Modifier},
15    modifier_nodes::SelectableTextElement,
16    selection_container::{
17        SelectableGeometry, SelectionGesture, SelectionRegistrar, TextSelection,
18    },
19    text::{AnnotatedString, TextLayoutOptions, TextStyle},
20    widgets::{Box, BoxSpec, LiquidTextMenu, MenuAnchor, TextMenuItem},
21};
22
23/// The container the `Text` composed here selects in: set by
24/// [`SelectionContainer`], cleared by [`DisableSelection`].
25pub fn local_selection_registrar() -> CompositionLocal<Option<SelectionRegistrar>> {
26    crate::environment_locals::cached_local(
27        |locals| &locals.selection_registrar,
28        || cranpose_core::compositionLocalOf(|| None),
29    )
30}
31
32/// Lets users select and copy text across the content. A mouse drag selects a
33/// range; double and triple clicks select a word and a line. A touch hold starts
34/// selection and a release opens the text menu. Ctrl+C and Ctrl+A use the copy and
35/// select-all actions; macOS uses Cmd. Escape clears the selection.
36///
37/// Wrap excluded content in [`DisableSelection`].
38///
39/// # Example
40///
41/// ```rust
42/// use cranpose_ui::{widgets::SelectionContainer, *};
43///
44/// #[composable]
45/// fn Article() {
46///     SelectionContainer(Modifier::empty(), || {
47///         Text(
48///             "Select and copy this text.",
49///             Modifier::empty(),
50///             TextStyle::default(),
51///         );
52///     });
53/// }
54/// ```
55#[composable]
56pub fn SelectionContainer(modifier: Modifier, content: impl FnMut() + 'static) {
57    let selection = rememberMutableStateOf(|| None::<TextSelection>);
58    let menu_open = rememberMutableStateOf(|| false);
59    let registrar = remember(move || SelectionRegistrar::new(selection)).with(Clone::clone);
60    let frame_clock = with_current_composer(|composer| composer.runtime_handle()).frame_clock();
61    let gesture = {
62        let registrar = registrar.clone();
63        remember(move || Rc::new(SelectionGesture::new(registrar, frame_clock, menu_open)))
64            .with(Rc::clone)
65    };
66    {
67        let gesture = Rc::clone(&gesture);
68        crate::UnhandledKeyEvents(move |event| gesture.on_key(event));
69    }
70    let content = Rc::new(RefCell::new(content));
71    let pointer = Rc::clone(&gesture);
72    CompositionLocalProvider(
73        [local_selection_registrar().provides(Some(registrar.clone()))],
74        move || {
75            let content = Rc::clone(&content);
76            Box(
77                modifier.pointer_input((), move |scope| {
78                    select_with_pointer(scope, Rc::clone(&pointer))
79                }),
80                BoxSpec::default(),
81                move || (content.borrow_mut())(),
82            );
83        },
84    );
85    if menu_open.get() {
86        selection_menu(&registrar, &gesture);
87    }
88}
89
90/// Feeds the container's pointer input to its selection gesture.
91async fn select_with_pointer(scope: PointerInputScope, gesture: Rc<SelectionGesture>) {
92    scope
93        .await_pointer_event_scope(|events| async move {
94            loop {
95                let event = events.await_pointer_event().await;
96                if event.is_consumed() {
97                    continue;
98                }
99                match event.kind {
100                    PointerEventKind::Down => {
101                        let secondary_only = event.buttons.contains(PointerButton::Secondary)
102                            && !event.buttons.contains(PointerButton::Primary);
103                        if !secondary_only {
104                            gesture.on_down(&event);
105                        }
106                    }
107                    PointerEventKind::Move => {
108                        if gesture.on_move(&event) {
109                            event.consume();
110                        }
111                    }
112                    PointerEventKind::Up => gesture.on_up(),
113                    PointerEventKind::Cancel => gesture.on_cancel(),
114                    _ => {}
115                }
116            }
117        })
118        .await;
119}
120
121/// The Copy / Select all menu over a selection a finger made.
122fn selection_menu(registrar: &SelectionRegistrar, gesture: &Rc<SelectionGesture>) {
123    let Some(line) = registrar.first_highlight_in_window() else {
124        return;
125    };
126    let copy = Rc::clone(gesture);
127    let everything = registrar.clone();
128    let items = vec![
129        TextMenuItem::new(crate::UiString::Copy.resolve(), move || {
130            copy.copy();
131            copy.dismiss();
132        }),
133        TextMenuItem::new(crate::UiString::SelectAll.resolve(), move || {
134            everything.select_all();
135        }),
136    ];
137    let anchor = MenuAnchor {
138        center_x: line.x + line.width * 0.5,
139        line_top: line.y,
140        line_bottom: line.y + line.height,
141    };
142    LiquidTextMenu(anchor, true, None, items);
143}
144
145/// Leaves the `Text` inside `content` out of the enclosing
146/// [`SelectionContainer`], as Jetpack Compose's `DisableSelection` does.
147#[composable]
148pub fn DisableSelection(content: impl FnOnce()) {
149    CompositionLocalProvider([local_selection_registrar().provides(None)], content);
150}
151
152/// What a `Text` in a [`SelectionContainer`] adds in front of its text: it
153/// tells the container what it shows and where, and draws its part of the
154/// selection behind its glyphs.
155#[composable]
156pub(crate) fn selectable_text(
157    registrar: SelectionRegistrar,
158    text: Rc<AnnotatedString>,
159    style: TextStyle,
160    options: TextLayoutOptions,
161) -> Modifier {
162    let key = {
163        let registrar = registrar.clone();
164        remember(move || registrar.subscribe()).with(|key| *key)
165    };
166    let geometry = remember(|| Rc::new(SelectableGeometry::default())).with(Rc::clone);
167    registrar.update(key, text, style, options, Rc::clone(&geometry));
168    {
169        let registrar = registrar.clone();
170        DisposableEffect(key, move |scope| {
171            scope.on_dispose(move || registrar.unsubscribe(key))
172        });
173    }
174    let reporter = Modifier::from_parts(&[modifier_element(SelectableTextElement::new(
175        Rc::clone(&geometry),
176    ))]);
177    reporter.draw_behind(move |scope| {
178        geometry.set_content_size(scope.size());
179        for line in registrar.highlight(key) {
180            scope.draw_rect_at(
181                line,
182                Brush::solid(crate::text_field_modifier_node::DEFAULT_SELECTION_COLOR),
183            );
184        }
185    })
186}
187
188#[cfg(test)]
189#[path = "tests/selection_container_tests.rs"]
190mod tests;