1use std::cell::Cell;
50use std::rc::Rc;
51
52use teksilo_canvas::{Rect, Size, SizeProposal};
53use teksilo_core::accessibility::AccessNodeBuilder;
54use teksilo_core::build_context::BuildContext;
55use teksilo_core::event::{EventResponse, Key, WidgetEvent};
56use teksilo_core::signal::{Prop, Signal};
57use teksilo_core::styles::{LinkStyleConfig, SharedLinkStyle};
58use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
59use teksilo_core::widget_builder::HandlerSet;
60use teksilo_core::widget_id::WidgetId;
61
62use crate::button::InteractionState;
63use teksilo_i18n::LocalizedString;
64
65type CommandFactory = Box<dyn Fn(&mut EventContext)>;
66
67pub struct Link {
69 text: LocalizedString,
70 url: Option<String>,
71 action: Option<CommandFactory>,
72 tooltip_text: Option<LocalizedString>,
73 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
74 composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
75 interaction: Option<Signal<InteractionState>>,
76 visited: Option<Prop<bool>>,
82 enabled: Prop<bool>,
85 style_override: Option<SharedLinkStyle>,
87 root_child_id: Option<WidgetId>,
88}
89
90impl Link {
91 pub fn new(text: impl Into<LocalizedString>) -> Self {
93 let ls: LocalizedString = text.into();
94 Self {
95 text: ls,
96 url: None,
97 action: None,
98 tooltip_text: None,
99 rich_tooltip_source: None,
100 composite_tooltip_content: None,
101 interaction: None,
102 visited: None,
103 enabled: Prop::Static(true),
104 style_override: None,
105 root_child_id: None,
106 }
107 }
108
109 pub fn visited(mut self, visited: impl Into<Prop<bool>>) -> Self {
114 self.visited = Some(visited.into());
115 self
116 }
117
118 pub fn style(mut self, style: impl teksilo_core::styles::LinkStyle) -> Self {
120 self.style_override = Some(Rc::new(style));
121 self
122 }
123
124 pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
126 self.action = Some(Box::new(f));
127 self
128 }
129
130 pub fn url(mut self, url: impl Into<String>) -> Self {
132 self.url = Some(url.into());
133 self
134 }
135
136 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
139 self.tooltip_text = Some(text.into());
140 self.rich_tooltip_source = None;
141 self.composite_tooltip_content = None;
142 self
143 }
144
145 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
148 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
149 self.tooltip_text = None;
150 self.composite_tooltip_content = None;
151 self
152 }
153
154 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
156 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
157 self.tooltip_text = None;
158 self.composite_tooltip_content = None;
159 self
160 }
161
162 pub fn composite_tooltip(
165 mut self,
166 content: impl teksilo_core::widget::Widget + 'static,
167 ) -> Self {
168 self.composite_tooltip_content = Some(Box::new(content));
169 self.tooltip_text = None;
170 self.rich_tooltip_source = None;
171 self
172 }
173
174 pub fn get_url(&self) -> Option<&str> {
176 self.url.as_deref()
177 }
178
179 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
183 self.enabled = enabled.into();
184 self
185 }
186}
187
188impl std::fmt::Debug for Link {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 f.debug_struct("Link").field("text", &self.text).finish()
191 }
192}
193
194impl Widget for Link {
195 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
196 let self_id = ctx.self_id();
197 ctx.enabled_when(self_id, self.enabled.clone());
199 let effective_enabled = ctx.effective_enabled_signal(self_id);
200
201 let interaction = ctx.signal(InteractionState::Idle);
202 self.interaction = Some(interaction.clone());
203
204 let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
208 let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
209 let is_focused = interaction
213 .map(|s| matches!(s, InteractionState::Focused))
214 .and(&ctx.focus_visible());
215 let is_visited = self
216 .visited
217 .as_ref()
218 .map(|p| p.as_signal())
219 .unwrap_or_else(|| Signal::new(false));
220 let is_disabled = effective_enabled.map(|on| !*on);
221
222 let style: SharedLinkStyle = self
223 .style_override
224 .clone()
225 .or_else(|| ctx.theme().style_slots.link.clone())
226 .unwrap_or_else(|| {
227 Rc::new(crate::styles::RecipeLinkStyle::for_tokens(
228 &ctx.theme().input,
229 ))
230 });
231 let root_id = style.make_body(
232 &LinkStyleConfig {
233 text: self.text.clone().into(),
234 is_hovered,
235 is_pressed,
236 is_focused,
237 is_visited,
238 is_disabled,
239 },
240 ctx,
241 );
242
243 if let Some(content) = self.composite_tooltip_content.take() {
244 let delay = ctx.theme().motion.tooltip_delay_heavy;
245 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
246 } else if let Some(source) = self.rich_tooltip_source.take() {
247 let delay = ctx.theme().motion.tooltip_delay;
248 crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
249 } else if let Some(tooltip_text) = self.tooltip_text.clone() {
250 let delay = ctx.theme().motion.tooltip_delay;
251 crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
252 }
253
254 self.root_child_id = Some(root_id);
255
256 let action = self.action.take();
258 let action_rc: std::rc::Rc<Option<CommandFactory>> = std::rc::Rc::new(action);
259 let action_for_tap = action_rc.clone();
260 let action_for_key = action_rc.clone();
261 let action_for_access = action_rc.clone();
262 let int_tap = interaction.clone();
263 let int_hover = interaction.clone();
264 let int_key = interaction.clone();
265 let int_focus = interaction.clone();
266
267 let pointer_over = Rc::new(Cell::new(false));
275 crate::button::bind_press_interaction(ctx, interaction.clone(), pointer_over.clone());
276
277 let handler_set = HandlerSet::new()
278 .on_tap({
279 let hovering = pointer_over.clone();
280 move |_pos, ctx: &mut EventContext| {
281 if let Some(ref action) = *action_for_tap {
282 action(ctx);
283 }
284 int_tap.set(if ctx.pointer_kind().hovers() {
285 hovering.set(true);
286 InteractionState::Hovered
287 } else {
288 InteractionState::Idle
289 });
290 }
291 })
292 .on_hover({
293 let hovering = pointer_over.clone();
294 move |entered: bool, _ctx: &mut EventContext| {
295 hovering.set(entered);
296 if entered {
297 int_hover.set(InteractionState::Hovered);
298 } else {
299 int_hover.set(InteractionState::Idle);
300 }
301 }
302 })
303 .on_key({
304 move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
305 match event {
306 WidgetEvent::KeyDown {
307 key: Key::Space | Key::Enter,
308 ..
309 } => {
310 int_key.set(InteractionState::Pressed);
311 EventResponse::Handled
312 }
313 WidgetEvent::KeyUp {
314 key: Key::Space | Key::Enter,
315 ..
316 } => {
317 if int_key.get() != InteractionState::Pressed {
322 return EventResponse::Ignored;
323 }
324 if let Some(ref action) = *action_for_key {
325 action(ctx);
326 }
327 int_key.set(InteractionState::Focused);
328 EventResponse::Handled
329 }
330 _ => EventResponse::Ignored,
331 }
332 }
333 })
334 .on_focus({
335 move |gained: bool, _ctx: &mut EventContext| {
336 if gained {
337 if int_focus.get() == InteractionState::Idle {
338 int_focus.set(InteractionState::Focused);
339 }
340 } else {
341 int_focus.set(InteractionState::Idle);
342 }
343 }
344 })
345 .on_access_action({
346 move |action: teksilo_core::accesskit::Action,
347 ctx: &mut EventContext|
348 -> EventResponse {
349 if action == teksilo_core::accesskit::Action::Click {
350 if let Some(ref act) = *action_for_access {
351 act(ctx);
352 }
353 EventResponse::Handled
354 } else {
355 EventResponse::Ignored
356 }
357 }
358 })
359 .focusable(true)
363 .cursor(CursorIcon::Pointer);
364
365 ctx.apply_self_handlers(handler_set);
366
367 vec![root_id]
368 }
369
370 fn layout_response(
371 &self,
372 proposal: SizeProposal,
373 ctx: &LayoutContext,
374 ) -> teksilo_core::widget::LayoutResponse {
375 if let Some(root) = self.root_child_id
376 && let Some(size) = ctx.child_size(root, proposal)
377 {
378 return (size).into();
379 }
380 proposal.resolve(0.0, 0.0).into()
381 }
382
383 fn place_children(
384 &self,
385 bounds: Rect,
386 _proposal: SizeProposal,
387 children: &mut [WidgetPlacement],
388 _ctx: &LayoutContext,
389 ) {
390 for child in children.iter_mut() {
391 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
392 child.size = Size::new(bounds.width, bounds.height);
393 }
394 }
395
396 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
397 builder.set_role(teksilo_core::accesskit::Role::Link);
398 builder.set_name(self.text.resolve_now());
399 if let Some(ref url) = self.url {
400 builder.set_url(url.clone());
401 }
402 builder.add_action(teksilo_core::accesskit::Action::Click);
406 builder.add_action(teksilo_core::accesskit::Action::Focus);
407 }
408
409 fn children(&self) -> Vec<WidgetId> {
410 self.root_child_id.into_iter().collect()
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use std::cell::Cell;
418 use teksilo_core::event::Modifiers;
419 use teksilo_core::widget_tree::WidgetTree;
420 use teksilo_i18n::lit;
421
422 #[test]
423 fn keyup_without_keydown_does_not_fire() {
424 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
427 let fired = Rc::new(Cell::new(0_u32));
428 let fired_for_link = fired.clone();
429 let link = tree.add(Link::new(lit!("T")).on_activate_fn(move |_ctx| {
430 fired_for_link.set(fired_for_link.get() + 1);
431 }));
432 tree.layout(SizeProposal::exact(200.0, 80.0));
433 tree.focus(link);
434
435 tree.dispatch_event(WidgetEvent::KeyUp {
436 key: Key::Enter,
437 modifiers: Modifiers::NONE,
438 });
439 assert_eq!(
440 fired.get(),
441 0,
442 "a lone KeyUp (no matching KeyDown) must not activate the link",
443 );
444
445 tree.dispatch_event(WidgetEvent::KeyDown {
446 key: Key::Enter,
447 modifiers: Modifiers::NONE,
448 text: None,
449 });
450 tree.dispatch_event(WidgetEvent::KeyUp {
451 key: Key::Enter,
452 modifiers: Modifiers::NONE,
453 });
454 assert_eq!(
455 fired.get(),
456 1,
457 "a matched KeyDown + KeyUp pair must activate exactly once",
458 );
459 }
460
461 struct PressProbe(std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>>);
466
467 impl teksilo_core::styles::LinkStyle for PressProbe {
468 fn make_body(
469 &self,
470 cfg: &teksilo_core::styles::LinkStyleConfig,
471 ctx: &mut BuildContext,
472 ) -> WidgetId {
473 *self.0.borrow_mut() = Some((cfg.is_pressed.clone(), cfg.is_hovered.clone()));
474 ctx.add(crate::primitives::FixedSize::new().width(60.0).height(18.0))
475 }
476 }
477
478 #[allow(clippy::type_complexity)]
479 fn probed_link_with_hover() -> (
480 WidgetTree,
481 WidgetId,
482 Signal<bool>,
483 Signal<bool>,
484 std::rc::Rc<Cell<u32>>,
485 ) {
486 let probe: std::rc::Rc<std::cell::RefCell<Option<(Signal<bool>, Signal<bool>)>>> =
487 std::rc::Rc::new(std::cell::RefCell::new(None));
488 let hits = std::rc::Rc::new(Cell::new(0_u32));
489 let counter = hits.clone();
490 let mut theme = teksilo_core::presets::intui::light();
491 theme.style_slots.link = Some(std::rc::Rc::new(PressProbe(probe.clone())));
492 let mut tree = WidgetTree::new().with_theme(theme);
493 let link = tree.add(
494 Link::new(lit!("Read more")).on_activate_fn(move |_| counter.set(counter.get() + 1)),
495 );
496 tree.layout(SizeProposal::exact(200.0, 60.0));
497 let (pressed, hovered) = probe.borrow().clone().expect("style ran");
498 (tree, link, pressed, hovered, hits)
499 }
500
501 fn probed_link() -> (WidgetTree, WidgetId, Signal<bool>, std::rc::Rc<Cell<u32>>) {
502 let (tree, link, pressed, _hovered, hits) = probed_link_with_hover();
503 (tree, link, pressed, hits)
504 }
505
506 #[test]
512 fn a_mouse_follow_rests_hovered_and_a_finger_follow_rests_idle() {
513 use crate::button::press_test_support::touch_tap;
514
515 let (mut tree, link, pressed, hovered, hits) = probed_link_with_hover();
516 let at = tree.bounds(link).center();
517 tree.pointer_move(at);
518 assert!(hovered.get(), "the pointer arrived over the link");
519 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
520 tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
521 assert_eq!(hits.get(), 1, "the release followed the link");
522 assert!(!pressed.get());
523 assert!(
524 hovered.get(),
525 "a mouse that clicked the link is still on it, so it rests hovered",
526 );
527
528 let (mut tree, link, pressed, hovered, hits) = probed_link_with_hover();
529 let at = tree.bounds(link).center();
530 touch_tap(&mut tree, at);
531 assert_eq!(hits.get(), 1, "the contact followed on its release");
532 assert!(!pressed.get());
533 assert!(
534 !hovered.get(),
535 "a finger leaves nothing behind, so the link must rest idle",
536 );
537 }
538
539 #[test]
544 fn a_mouse_press_lights_the_pressed_state_and_the_release_follows() {
545 let (mut tree, link, pressed, hits) = probed_link();
546 let at = tree.bounds(link).center();
547 tree.pointer_move(at);
548 tree.pointer_down_button(at, teksilo_core::event::PointerButton::Primary);
549 assert!(pressed.get());
550 assert_eq!(hits.get(), 0);
551 tree.pointer_up_button(at, teksilo_core::event::PointerButton::Primary);
552 assert!(!pressed.get());
553 assert_eq!(hits.get(), 1);
554 }
555
556 #[test]
558 fn a_touch_tap_follows_on_release_and_a_slide_off_abandons_it() {
559 use crate::button::press_test_support::{finger, touch};
560 use teksilo_core::pointer::PointerPhase;
561
562 let (mut tree, link, pressed, hits) = probed_link();
563 let bounds = tree.bounds(link);
564 let at = bounds.center();
565 let away = teksilo_canvas::Point::new(at.x, bounds.y + bounds.height + 80.0);
566
567 let id = finger();
568 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
569 assert!(pressed.get());
570 tree.dispatch_pointer(touch(id, PointerPhase::Move, away, 20));
571 assert!(!pressed.get());
572 tree.dispatch_pointer(touch(id, PointerPhase::Up, away, 40));
573 assert_eq!(hits.get(), 0);
574
575 let id = finger();
576 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 100));
577 tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 130));
578 assert_eq!(hits.get(), 1);
579 }
580}