cranpose_ui/widgets/
linked_text.rs1#![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#[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
92fn 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}