Skip to main content

cranpose_ui/widgets/
clickable_text.rs

1//! ClickableText widget for handling clicks on annotated text.
2//!
3//! Mirrors Jetpack Compose's `ClickableText` from:
4//! `compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/ClickableText.kt`
5
6#![allow(non_snake_case)]
7
8use crate::modifier::Modifier;
9use crate::text::{AnnotatedString, TextOverflow, TextStyle};
10use crate::widgets::BasicText;
11use cranpose_core::NodeId;
12use std::rc::Rc;
13
14#[doc(hidden)]
15pub trait IntoSharedAnnotatedString {
16    fn into_shared(self) -> Rc<AnnotatedString>;
17}
18
19impl IntoSharedAnnotatedString for AnnotatedString {
20    fn into_shared(self) -> Rc<AnnotatedString> {
21        Rc::new(self)
22    }
23}
24
25impl IntoSharedAnnotatedString for Rc<AnnotatedString> {
26    fn into_shared(self) -> Rc<AnnotatedString> {
27        self
28    }
29}
30
31/// Displays an [`AnnotatedString`] and calls `on_click` with the **byte offset** of the character
32/// under the pointer at the time of the click.
33///
34/// Callers typically use the offset to query string annotations:
35///
36/// ```rust,ignore
37/// ClickableText(
38///     text.clone(),
39///     Modifier::empty(),
40///     TextStyle::default(),
41///     |offset| {
42///         for ann in text.get_string_annotations("URL", offset, offset + 1) {
43///             uri_handler.open_uri(&ann.item.annotation).ok();
44///         }
45///     },
46/// );
47/// ```
48///
49/// # JC parity
50///
51/// ```kotlin
52/// @Composable
53/// fun ClickableText(
54///     text: AnnotatedString,
55///     modifier: Modifier = Modifier,
56///     style: TextStyle = TextStyle.Default,
57///     onClick: (Int) -> Unit,
58/// )
59/// ```
60#[allow(clippy::needless_pass_by_value)]
61pub fn ClickableText<T>(
62    text: T,
63    modifier: Modifier,
64    style: TextStyle,
65    on_click: impl Fn(usize) + 'static,
66) -> NodeId
67where
68    T: IntoSharedAnnotatedString,
69{
70    let text = text.into_shared();
71    let text_for_click = text.clone();
72    let style_for_click = style.clone();
73    let on_click: Rc<dyn Fn(usize)> = Rc::new(on_click);
74
75    let clickable_modifier = modifier.clickable(move |point| {
76        let offset = crate::text::get_offset_for_position(
77            &text_for_click,
78            &style_for_click,
79            point.x,
80            point.y,
81        );
82        on_click(offset);
83    });
84
85    BasicText(
86        text,
87        clickable_modifier,
88        style,
89        TextOverflow::Clip,
90        true,
91        usize::MAX,
92        1,
93    )
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use cranpose_core::{location_key, Composition, MemoryApplier};
100
101    #[test]
102    fn clickable_text_composes_without_panic() {
103        let mut comp = Composition::new(MemoryApplier::new());
104        comp.render(location_key(file!(), line!(), column!()), || {
105            ClickableText(
106                AnnotatedString::from("Hello"),
107                Modifier::empty(),
108                TextStyle::default(),
109                |_offset| {},
110            );
111        })
112        .expect("composition succeeds");
113    }
114}