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#![expect(non_snake_case, reason = "names follow Compose's casing")]
7
8use std::{cell::Cell, rc::Rc};
9
10use cranpose_core::{NodeId, remember};
11use cranpose_ui_graphics::Size;
12
13use crate::{
14    modifier::Modifier,
15    text::{AnnotatedString, TextLayoutOptions, TextOverflow, TextStyle, measure},
16    widgets::BasicTextWithOptions,
17};
18
19#[doc(hidden)]
20pub trait IntoSharedAnnotatedString {
21    fn into_shared(self) -> Rc<AnnotatedString>;
22}
23
24impl IntoSharedAnnotatedString for AnnotatedString {
25    fn into_shared(self) -> Rc<AnnotatedString> {
26        Rc::new(self)
27    }
28}
29
30impl IntoSharedAnnotatedString for Rc<AnnotatedString> {
31    fn into_shared(self) -> Rc<AnnotatedString> {
32        self
33    }
34}
35
36/// Displays annotated text and calls `on_click` with the byte offset under the pointer.
37/// Use [`LinkedText`](crate::LinkedText) for URL and custom-action annotations.
38///
39/// # Example
40///
41/// ```rust
42/// use cranpose_ui::{text::AnnotatedString, *};
43///
44/// #[composable]
45/// fn OffsetLabel() {
46///     ClickableText(
47///         AnnotatedString::from("Click a character"),
48///         Modifier::empty(),
49///         TextStyle::default(),
50///         |offset| println!("Byte offset: {offset}"),
51///     );
52/// }
53/// ```
54pub fn ClickableText<T>(
55    text: T,
56    modifier: Modifier,
57    style: TextStyle,
58    on_click: impl Fn(usize) + 'static,
59) -> NodeId
60where
61    T: IntoSharedAnnotatedString,
62{
63    let text = text.into_shared();
64    let text_for_click = Rc::clone(&text);
65    let style_for_click = style.clone();
66    let options = TextLayoutOptions {
67        overflow: TextOverflow::Clip,
68        soft_wrap: true,
69        max_lines: usize::MAX,
70        min_lines: 1,
71    };
72    // The click finds its offset in the text wrapped as the node draws it,
73    // at the width of the node's content.
74    let size = remember(|| Rc::new(Cell::new(Size::default()))).with(Rc::clone);
75    let clickable_modifier = modifier
76        .report_size(Rc::clone(&size))
77        .clickable(move |point| {
78            on_click(measure::offset_in_drawn_text(
79                &text_for_click,
80                &style_for_click,
81                options,
82                size.get().width,
83                point,
84            ));
85        });
86
87    BasicTextWithOptions(text, clickable_modifier, style, options)
88}
89
90#[cfg(test)]
91#[path = "tests/clickable_text_tests.rs"]
92mod tests;