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    thread_local! {
27        static LOCAL: RefCell<Option<CompositionLocal<Option<SelectionRegistrar>>>> =
28            const { RefCell::new(None) };
29    }
30    LOCAL.with(|cell| {
31        cell.borrow_mut()
32            .get_or_insert_with(|| cranpose_core::compositionLocalOf(|| None))
33            .clone()
34    })
35}
36
37/// Makes the `Text` inside `content` selectable, as Jetpack Compose's
38/// `SelectionContainer` does.
39///
40/// A mouse selects by dragging; a double click selects a word and a triple
41/// click a line, and dragging after either grows the selection by words or
42/// lines. A finger resting on a word selects it and then drags to grow the
43/// selection, and lifting it opens a Copy / Select all menu. Ctrl+C (Cmd+C on
44/// macOS) copies the selection, Ctrl+A (Cmd+A) selects all of this
45/// container's text and Escape clears it. A selection runs across all the
46/// texts in the container, in reading order; wrap part of `content` in
47/// [`DisableSelection`] to leave it out.
48///
49/// ```rust,ignore
50/// SelectionContainer(Modifier::empty(), || {
51///     Text("You can long-press and select this text!", Modifier::empty(), TextStyle::default());
52/// });
53/// ```
54#[composable]
55pub fn SelectionContainer(modifier: Modifier, content: impl FnMut() + 'static) {
56    let selection = rememberMutableStateOf(|| None::<TextSelection>);
57    let menu_open = rememberMutableStateOf(|| false);
58    let registrar = remember(move || SelectionRegistrar::new(selection)).with(Clone::clone);
59    let frame_clock = with_current_composer(|composer| composer.runtime_handle()).frame_clock();
60    let gesture = {
61        let registrar = registrar.clone();
62        remember(move || Rc::new(SelectionGesture::new(registrar, frame_clock, menu_open)))
63            .with(Rc::clone)
64    };
65    {
66        let gesture = Rc::clone(&gesture);
67        crate::UnhandledKeyEvents(move |event| gesture.on_key(event));
68    }
69    let content = Rc::new(RefCell::new(content));
70    let pointer = Rc::clone(&gesture);
71    CompositionLocalProvider(
72        vec![local_selection_registrar().provides(Some(registrar.clone()))],
73        move || {
74            let content = Rc::clone(&content);
75            Box(
76                modifier.pointer_input((), move |scope| {
77                    select_with_pointer(scope, Rc::clone(&pointer))
78                }),
79                BoxSpec::default(),
80                move || (content.borrow_mut())(),
81            );
82        },
83    );
84    if menu_open.get() {
85        selection_menu(&registrar, &gesture);
86    }
87}
88
89/// Feeds the container's pointer input to its selection gesture.
90async fn select_with_pointer(scope: PointerInputScope, gesture: Rc<SelectionGesture>) {
91    scope
92        .await_pointer_event_scope(|events| async move {
93            loop {
94                let event = events.await_pointer_event().await;
95                if event.is_consumed() {
96                    continue;
97                }
98                match event.kind {
99                    PointerEventKind::Down => {
100                        let secondary_only = event.buttons.contains(PointerButton::Secondary)
101                            && !event.buttons.contains(PointerButton::Primary);
102                        if !secondary_only {
103                            gesture.on_down(&event);
104                        }
105                    }
106                    PointerEventKind::Move => {
107                        if gesture.on_move(&event) {
108                            event.consume();
109                        }
110                    }
111                    PointerEventKind::Up => gesture.on_up(),
112                    PointerEventKind::Cancel => gesture.on_cancel(),
113                    _ => {}
114                }
115            }
116        })
117        .await;
118}
119
120/// The Copy / Select all menu over a selection a finger made.
121fn selection_menu(registrar: &SelectionRegistrar, gesture: &Rc<SelectionGesture>) {
122    let Some(line) = registrar.first_highlight_in_window() else {
123        return;
124    };
125    let copy = Rc::clone(gesture);
126    let everything = registrar.clone();
127    let items = vec![
128        TextMenuItem::new("Copy", move || {
129            copy.copy();
130            copy.dismiss();
131        }),
132        TextMenuItem::new("Select all", move || everything.select_all()),
133    ];
134    let anchor = MenuAnchor {
135        center_x: line.x + line.width * 0.5,
136        line_top: line.y,
137        line_bottom: line.y + line.height,
138    };
139    LiquidTextMenu(anchor, true, None, items);
140}
141
142/// Leaves the `Text` inside `content` out of the enclosing
143/// [`SelectionContainer`], as Jetpack Compose's `DisableSelection` does.
144#[composable]
145pub fn DisableSelection(content: impl FnOnce()) {
146    CompositionLocalProvider(vec![local_selection_registrar().provides(None)], content);
147}
148
149/// What a `Text` in a [`SelectionContainer`] adds in front of its text: it
150/// tells the container what it shows and where, and draws its part of the
151/// selection behind its glyphs.
152#[composable]
153pub(crate) fn selectable_text(
154    registrar: SelectionRegistrar,
155    text: Rc<AnnotatedString>,
156    style: TextStyle,
157    options: TextLayoutOptions,
158) -> Modifier {
159    let key = {
160        let registrar = registrar.clone();
161        remember(move || registrar.subscribe()).with(|key| *key)
162    };
163    let geometry = remember(|| Rc::new(SelectableGeometry::default())).with(Rc::clone);
164    registrar.update(key, text, style, options, Rc::clone(&geometry));
165    {
166        let registrar = registrar.clone();
167        DisposableEffect(key, move |scope| {
168            scope.on_dispose(move || registrar.unsubscribe(key))
169        });
170    }
171    let reporter = Modifier::from_parts(vec![modifier_element(SelectableTextElement::new(
172        Rc::clone(&geometry),
173    ))]);
174    reporter.draw_behind(move |scope| {
175        geometry.set_content_size(scope.size());
176        for line in registrar.highlight(key) {
177            scope.draw_rect_at(
178                line,
179                Brush::solid(crate::text_field_modifier_node::DEFAULT_SELECTION_COLOR),
180            );
181        }
182    })
183}
184
185#[cfg(test)]
186#[path = "tests/selection_container_tests.rs"]
187mod tests;