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