Skip to main content

cranpose_ui/widgets/
linked_text.rs

1//! LinkedText widget — renders AnnotatedString with link annotations auto-handled.
2//!
3//! Mirrors the behaviour of Jetpack Compose `BasicText` / `Text` when the
4//! annotated string contains `LinkAnnotation.Url` or `LinkAnnotation.Clickable`.
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, LinkAnnotation, TextStyle},
15    widgets::ClickableText,
16};
17
18/// Renders an [`AnnotatedString`] and automatically dispatches link clicks:
19///
20/// - [`LinkAnnotation::Url`] → calls `open_url(url)` (platform provides the URI handler).
21/// - [`LinkAnnotation::Clickable`] → calls the handler stored in the annotation.
22///
23/// # Example — opening a URL
24///
25/// ```rust,ignore
26/// let uri_handler = local_uri_handler().current();
27/// let text = AnnotatedString::builder()
28///     .append("Visit the ")
29///     .with_link(
30///         LinkAnnotation::Url("https://developer.android.com/".into()),
31///         |b| b.append("Android Developers"),
32///     )
33///     .append(" site.")
34///     .to_annotated_string();
35///
36/// LinkedText(
37///     text,
38///     Modifier::empty(),
39///     TextStyle::default(),
40///     move |url| { uri_handler.open_uri(url).ok(); },
41/// );
42/// ```
43///
44/// # Example — custom action (`LinkAnnotation::Clickable`)
45///
46/// ```rust,ignore
47/// let text = AnnotatedString::builder()
48///     .append("Click ")
49///     .with_link(
50///         LinkAnnotation::Clickable {
51///             tag: "action".into(),
52///             handler: Rc::new(move || println!("clicked!")),
53///         },
54///         |b| b.append("here"),
55///     )
56///     .to_annotated_string();
57///
58/// // open_url is never called for Clickable — pass a no-op.
59/// LinkedText(text, Modifier::empty(), TextStyle::default(), |_| {});
60/// ```
61///
62/// # JC parity
63///
64/// Equivalent to `Text(buildAnnotatedString { withLink(LinkAnnotation.Url(…)) { … } })`.
65/// The `open_url` parameter corresponds to the platform-provided `LocalUriHandler`.
66#[allow(clippy::needless_pass_by_value)]
67pub fn LinkedText(
68    text: AnnotatedString,
69    modifier: Modifier,
70    style: TextStyle,
71    open_url: impl Fn(&str) + 'static,
72) -> NodeId {
73    let text = Rc::new(text);
74    let text_for_links = text.clone();
75    let open_url: Rc<dyn Fn(&str)> = Rc::new(open_url);
76    let modifier = modifier.semantics(link_actions(Rc::clone(&text), Rc::clone(&open_url)));
77
78    ClickableText(text, modifier, style, move |offset| {
79        for ann in text_for_links
80            .link_annotations
81            .iter()
82            .filter(|a| a.range.start <= offset && offset < a.range.end)
83        {
84            match &ann.item {
85                LinkAnnotation::Url(url) => open_url(url),
86                LinkAnnotation::Clickable { handler, .. } => handler(),
87            }
88        }
89    })
90}
91
92/// One action per link for a screen reader's actions menu, so a person who
93/// cannot aim a tap at one word still opens every link the text holds.
94fn link_actions(
95    text: Rc<AnnotatedString>,
96    open_url: Rc<dyn Fn(&str)>,
97) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) {
98    move |config| {
99        for link in &text.link_annotations {
100            let shown = text.text.get(link.range.clone());
101            let (label, action) = link_action(shown, &link.item, &open_url);
102            config
103                .custom_actions
104                .push(cranpose_foundation::SemanticsCustomAction::new(
105                    label,
106                    move || action(),
107                ));
108        }
109    }
110}
111
112fn link_action(
113    shown: Option<&str>,
114    link: &LinkAnnotation,
115    open_url: &Rc<dyn Fn(&str)>,
116) -> (String, Rc<dyn Fn()>) {
117    let shown = shown.map(str::trim).filter(|shown| !shown.is_empty());
118    match link {
119        LinkAnnotation::Url(url) => {
120            let label = format!("Open {}", shown.unwrap_or(url));
121            let open_url = Rc::clone(open_url);
122            let url = url.clone();
123            (label, Rc::new(move || open_url(&url)))
124        }
125        LinkAnnotation::Clickable { tag, handler } => {
126            let label = format!("Open {}", shown.unwrap_or(tag));
127            (label, Rc::clone(handler))
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use std::cell::RefCell;
135
136    use super::*;
137
138    fn text_with_links() -> AnnotatedString {
139        AnnotatedString::builder()
140            .append("Read the ")
141            .with_link(
142                LinkAnnotation::Url("https://example.test/docs".into()),
143                |builder| builder.append("docs"),
144            )
145            .append(" or ")
146            .with_link(
147                LinkAnnotation::Clickable {
148                    tag: "help".into(),
149                    handler: Rc::new(|| {}),
150                },
151                |builder| builder.append("ask"),
152            )
153            .to_annotated_string()
154    }
155
156    #[test]
157    fn a_reader_opens_each_link_from_the_actions_menu() {
158        let opened = Rc::new(RefCell::new(Vec::new()));
159        let open_url: Rc<dyn Fn(&str)> = {
160            let opened = Rc::clone(&opened);
161            Rc::new(move |url: &str| opened.borrow_mut().push(url.to_owned()))
162        };
163        let mut config = cranpose_foundation::SemanticsConfiguration::default();
164
165        link_actions(Rc::new(text_with_links()), open_url)(&mut config);
166        let labels: Vec<_> = config
167            .custom_actions
168            .iter()
169            .map(|action| action.label.as_str())
170            .collect();
171        config.custom_actions[0].invoke();
172
173        assert_eq!(labels, ["Open docs", "Open ask"]);
174        assert_eq!(
175            *opened.borrow(),
176            vec!["https://example.test/docs".to_owned()]
177        );
178    }
179
180    #[test]
181    fn a_link_with_no_shown_text_is_named_by_its_target() {
182        let text = AnnotatedString::builder()
183            .with_link(
184                LinkAnnotation::Url("https://example.test/".into()),
185                |builder| builder,
186            )
187            .to_annotated_string();
188        let mut config = cranpose_foundation::SemanticsConfiguration::default();
189
190        link_actions(Rc::new(text), Rc::new(|_: &str| {}))(&mut config);
191
192        assert_eq!(config.custom_actions[0].label, "Open https://example.test/");
193    }
194}