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#![expect(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/// Displays annotated text and dispatches each link action. A URL annotation
19/// calls `open_url`; a clickable annotation calls its own handler. A shared
20/// [`Rc<AnnotatedString>`] retains the prepared text across uses.
21///
22/// # Example
23///
24/// ```rust
25/// use cranpose_ui::{text::AnnotatedString, *};
26///
27/// #[composable]
28/// fn WebsiteLink() {
29///     let text = AnnotatedString::builder()
30///         .append("Visit ")
31///         .with_link(
32///             LinkAnnotation::Url("https://example.com".into()),
33///             |builder| builder.append("the website"),
34///         )
35///         .to_annotated_string();
36///     LinkedText(text, Modifier::empty(), TextStyle::default(), |url| {
37///         println!("Open URL: {url}");
38///     });
39/// }
40/// ```
41///
42/// An app can pass a callback from the platform URI service to open the URL.
43pub fn LinkedText(
44    text: impl Into<Rc<AnnotatedString>>,
45    modifier: Modifier,
46    style: TextStyle,
47    open_url: impl Fn(&str) + 'static,
48) -> NodeId {
49    let text = text.into();
50    let text_for_links = text.clone();
51    let open_url: Rc<dyn Fn(&str)> = Rc::new(open_url);
52    let modifier = modifier.stable_semantics(link_actions(Rc::clone(&text), Rc::clone(&open_url)));
53
54    ClickableText(text, modifier, style, move |offset| {
55        for ann in text_for_links
56            .link_annotations
57            .iter()
58            .filter(|a| a.range.start <= offset && offset < a.range.end)
59        {
60            match &ann.item {
61                LinkAnnotation::Url(url) => open_url(url),
62                LinkAnnotation::Clickable { handler, .. } => handler(),
63            }
64        }
65    })
66}
67
68/// One action per link for a screen reader's actions menu, so a person who
69/// cannot aim a tap at one word still opens every link the text holds.
70fn link_actions(
71    text: Rc<AnnotatedString>,
72    open_url: Rc<dyn Fn(&str)>,
73) -> impl Fn(&mut cranpose_foundation::SemanticsConfiguration) {
74    move |config| {
75        for link in &text.link_annotations {
76            let shown = text.text.get(link.range.clone());
77            let (label, action) = link_action(shown, &link.item, &open_url);
78            config
79                .custom_actions
80                .push(cranpose_foundation::SemanticsCustomAction::new(
81                    label,
82                    move || action(),
83                ));
84        }
85    }
86}
87
88fn link_action(
89    shown: Option<&str>,
90    link: &LinkAnnotation,
91    open_url: &Rc<dyn Fn(&str)>,
92) -> (String, Rc<dyn Fn()>) {
93    let shown = shown.map(str::trim).filter(|shown| !shown.is_empty());
94    match link {
95        LinkAnnotation::Url(url) => {
96            let label = format!("Open {}", shown.unwrap_or(url));
97            let open_url = Rc::clone(open_url);
98            let url = url.clone();
99            (label, Rc::new(move || open_url(&url)))
100        }
101        LinkAnnotation::Clickable { tag, handler } => {
102            let label = format!("Open {}", shown.unwrap_or(tag));
103            (label, Rc::clone(handler))
104        }
105    }
106}
107
108#[cfg(test)]
109#[path = "tests/linked_text_tests.rs"]
110mod tests;