1use std::rc::Rc;
31
32use teksilo_canvas::{Rect, Size, SizeProposal};
33use teksilo_core::accessibility::AccessNodeBuilder;
34use teksilo_core::build_context::BuildContext;
35use teksilo_core::event::{EventResponse, Key, WidgetEvent};
36use teksilo_core::signal::{Prop, Signal};
37use teksilo_core::styles::{LinkStyleConfig, SharedLinkStyle};
38use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
39use teksilo_core::widget_builder::HandlerSet;
40use teksilo_core::widget_id::WidgetId;
41
42use crate::button::InteractionState;
43use teksilo_i18n::LocalizedString;
44
45type CommandFactory = Box<dyn Fn(&mut EventContext)>;
46
47pub struct Link {
49 text: LocalizedString,
50 url: Option<String>,
51 action: Option<CommandFactory>,
52 tooltip_text: Option<LocalizedString>,
53 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
54 composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
55 interaction: Option<Signal<InteractionState>>,
56 visited: Option<Prop<bool>>,
62 enabled: Prop<bool>,
65 style_override: Option<SharedLinkStyle>,
67 root_child_id: Option<WidgetId>,
68}
69
70impl Link {
71 pub fn new(text: impl Into<LocalizedString>) -> Self {
73 let ls: LocalizedString = text.into();
74 Self {
75 text: ls,
76 url: None,
77 action: None,
78 tooltip_text: None,
79 rich_tooltip_source: None,
80 composite_tooltip_content: None,
81 interaction: None,
82 visited: None,
83 enabled: Prop::Static(true),
84 style_override: None,
85 root_child_id: None,
86 }
87 }
88
89 pub fn visited(mut self, visited: impl Into<Prop<bool>>) -> Self {
94 self.visited = Some(visited.into());
95 self
96 }
97
98 pub fn style(mut self, style: impl teksilo_core::styles::LinkStyle) -> Self {
100 self.style_override = Some(Rc::new(style));
101 self
102 }
103
104 pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
106 self.action = Some(Box::new(f));
107 self
108 }
109
110 pub fn url(mut self, url: impl Into<String>) -> Self {
112 self.url = Some(url.into());
113 self
114 }
115
116 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
119 self.tooltip_text = Some(text.into());
120 self.rich_tooltip_source = None;
121 self.composite_tooltip_content = None;
122 self
123 }
124
125 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
128 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
129 self.tooltip_text = None;
130 self.composite_tooltip_content = None;
131 self
132 }
133
134 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
136 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
137 self.tooltip_text = None;
138 self.composite_tooltip_content = None;
139 self
140 }
141
142 pub fn composite_tooltip(
145 mut self,
146 content: impl teksilo_core::widget::Widget + 'static,
147 ) -> Self {
148 self.composite_tooltip_content = Some(Box::new(content));
149 self.tooltip_text = None;
150 self.rich_tooltip_source = None;
151 self
152 }
153
154 pub fn get_url(&self) -> Option<&str> {
156 self.url.as_deref()
157 }
158
159 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
163 self.enabled = enabled.into();
164 self
165 }
166}
167
168impl std::fmt::Debug for Link {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 f.debug_struct("Link").field("text", &self.text).finish()
171 }
172}
173
174impl Widget for Link {
175 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
176 let self_id = ctx.self_id();
177 ctx.enabled_when(self_id, self.enabled.clone());
179 let effective_enabled = ctx.effective_enabled_signal(self_id);
180
181 let interaction = ctx.signal(InteractionState::Idle);
182 self.interaction = Some(interaction.clone());
183
184 let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
188 let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
189 let is_focused = interaction
193 .map(|s| matches!(s, InteractionState::Focused))
194 .and(&ctx.focus_visible());
195 let is_visited = self
196 .visited
197 .as_ref()
198 .map(|p| p.as_signal())
199 .unwrap_or_else(|| Signal::new(false));
200 let is_disabled = effective_enabled.map(|on| !*on);
201
202 let style: SharedLinkStyle = self
203 .style_override
204 .clone()
205 .or_else(|| ctx.theme().style_slots.link.clone())
206 .unwrap_or_else(|| Rc::new(crate::styles::RecipeLinkStyle::default()));
207 let root_id = style.make_body(
208 &LinkStyleConfig {
209 text: self.text.clone().into(),
210 is_hovered,
211 is_pressed,
212 is_focused,
213 is_visited,
214 is_disabled,
215 },
216 ctx,
217 );
218
219 if let Some(content) = self.composite_tooltip_content.take() {
220 let delay = ctx.theme().motion.tooltip_delay_heavy;
221 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
222 } else if let Some(source) = self.rich_tooltip_source.take() {
223 let delay = ctx.theme().motion.tooltip_delay;
224 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
225 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
226 let delay = ctx.theme().motion.tooltip_delay;
227 crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
228 }
229
230 self.root_child_id = Some(root_id);
231
232 let action = self.action.take();
234 let action_rc: std::rc::Rc<Option<CommandFactory>> = std::rc::Rc::new(action);
235 let action_for_tap = action_rc.clone();
236 let action_for_key = action_rc.clone();
237 let action_for_access = action_rc.clone();
238 let int_tap = interaction.clone();
239 let int_hover = interaction.clone();
240 let int_key = interaction.clone();
241 let int_focus = interaction.clone();
242
243 let handler_set = HandlerSet::new()
244 .on_tap({
245 move |_pos, ctx: &mut EventContext| {
246 if let Some(ref action) = *action_for_tap {
247 action(ctx);
248 }
249 int_tap.set(InteractionState::Hovered);
250 }
251 })
252 .on_hover({
253 move |entered: bool, _ctx: &mut EventContext| {
254 if entered {
255 int_hover.set(InteractionState::Hovered);
256 } else {
257 int_hover.set(InteractionState::Idle);
258 }
259 }
260 })
261 .on_key({
262 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
263 match event {
264 WidgetEvent::KeyDown {
265 key: Key::Space | Key::Enter,
266 ..
267 } => {
268 int_key.set(InteractionState::Pressed);
269 EventResponse::Handled
270 }
271 WidgetEvent::KeyUp {
272 key: Key::Space | Key::Enter,
273 ..
274 } => {
275 if int_key.get() != InteractionState::Pressed {
280 return EventResponse::Ignored;
281 }
282 if let Some(ref action) = *action_for_key {
283 action(ctx);
284 }
285 int_key.set(InteractionState::Focused);
286 EventResponse::Handled
287 }
288 _ => EventResponse::Ignored,
289 }
290 }
291 })
292 .on_focus({
293 move |gained: bool, _ctx: &mut EventContext| {
294 if gained {
295 if int_focus.get() == InteractionState::Idle {
296 int_focus.set(InteractionState::Focused);
297 }
298 } else {
299 int_focus.set(InteractionState::Idle);
300 }
301 }
302 })
303 .on_access_action({
304 move |action: teksilo_core::accesskit::Action,
305 ctx: &mut EventContext|
306 -> EventResponse {
307 if action == teksilo_core::accesskit::Action::Click {
308 if let Some(ref act) = *action_for_access {
309 act(ctx);
310 }
311 EventResponse::Handled
312 } else {
313 EventResponse::Ignored
314 }
315 }
316 })
317 .focusable(true)
321 .cursor(CursorIcon::Pointer);
322
323 ctx.apply_self_handlers(handler_set);
324
325 vec![root_id]
326 }
327
328 fn layout_response(
329 &self,
330 proposal: SizeProposal,
331 ctx: &LayoutContext,
332 ) -> teksilo_core::widget::LayoutResponse {
333 if let Some(root) = self.root_child_id
334 && let Some(size) = ctx.child_size(root, proposal)
335 {
336 return (size).into();
337 }
338 proposal.resolve(0.0, 0.0).into()
339 }
340
341 fn place_children(
342 &self,
343 bounds: Rect,
344 _proposal: SizeProposal,
345 children: &mut [WidgetPlacement],
346 _ctx: &LayoutContext,
347 ) {
348 for child in children.iter_mut() {
349 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
350 child.size = Size::new(bounds.width, bounds.height);
351 }
352 }
353
354 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
355 builder.set_role(teksilo_core::accesskit::Role::Link);
356 builder.set_name(self.text.resolve_now());
357 if let Some(ref url) = self.url {
358 builder.set_url(url.clone());
359 }
360 builder.add_action(teksilo_core::accesskit::Action::Click);
364 builder.add_action(teksilo_core::accesskit::Action::Focus);
365 }
366
367 fn children(&self) -> Vec<WidgetId> {
368 self.root_child_id.into_iter().collect()
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use std::cell::Cell;
376 use teksilo_core::event::Modifiers;
377 use teksilo_core::widget_tree::WidgetTree;
378 use teksilo_i18n::lit;
379
380 #[test]
381 fn keyup_without_keydown_does_not_fire() {
382 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
385 let fired = Rc::new(Cell::new(0_u32));
386 let fired_for_link = fired.clone();
387 let link = tree.add(Link::new(lit!("T")).on_activate_fn(move |_ctx| {
388 fired_for_link.set(fired_for_link.get() + 1);
389 }));
390 tree.layout(SizeProposal::exact(200.0, 80.0));
391 tree.focus(link);
392
393 tree.dispatch_event(WidgetEvent::KeyUp {
394 key: Key::Enter,
395 modifiers: Modifiers::NONE,
396 });
397 assert_eq!(
398 fired.get(),
399 0,
400 "a lone KeyUp (no matching KeyDown) must not activate the link",
401 );
402
403 tree.dispatch_event(WidgetEvent::KeyDown {
404 key: Key::Enter,
405 modifiers: Modifiers::NONE,
406 text: None,
407 });
408 tree.dispatch_event(WidgetEvent::KeyUp {
409 key: Key::Enter,
410 modifiers: Modifiers::NONE,
411 });
412 assert_eq!(
413 fired.get(),
414 1,
415 "a matched KeyDown + KeyUp pair must activate exactly once",
416 );
417 }
418}