teksilo_widgets/styles/recipe_link_style.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `LinkStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeLinkStyle` ships the IntUI link chrome: idle / hover /
7//! visited text colours via the standard `TextRole::Link*` roles, a
8//! 1 px underline matching the text colour, and a corner-rounded focus
9//! ring that appears only on keyboard focus.
10
11use teksilo_core::build_context::BuildContext;
12use teksilo_core::signal::Signal;
13use teksilo_core::styles::{LinkStyle, LinkStyleConfig};
14use teksilo_core::widget_id::WidgetId;
15use teksilo_tokens::{BorderRole, CornerRadius, TextRole, TextStyleRole};
16
17use crate::primitives::{FixedSize, RectWidget, TextWidget, VStack, ZStack};
18
19// IntUI design tokens for Link. The recipe owns its own dimensions.
20pub const LINK_CORNER_RADIUS: f32 = 4.0;
21pub const LINK_UNDERLINE_THICKNESS: f32 = 1.0;
22
23/// Per-state text role for a link, given its four interaction signals
24/// plus a static disabled hint. Exposed so custom `LinkStyle`
25/// implementations can reuse the IntUI mapping when they only want to
26/// swap the underline / focus-ring policy. Hover and pressed both map
27/// to `LinkHover`; visited overrides idle but is itself overridden by
28/// hover (standard web convention); disabled wins outright.
29pub fn link_text_role(
30 hovered: bool,
31 pressed: bool,
32 focused: bool,
33 visited: bool,
34 disabled: bool,
35) -> TextRole {
36 if disabled {
37 return TextRole::Disabled;
38 }
39 if hovered || pressed {
40 return TextRole::LinkHover;
41 }
42 if visited {
43 return TextRole::LinkVisited;
44 }
45 let _ = focused; // focus is signalled by the border ring, not the text colour.
46 TextRole::Link
47}
48
49/// Configurable dimensions for [`RecipeLinkStyle`].
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct LinkRecipe {
52 pub corner_radius: f32,
53 pub underline_thickness: f32,
54}
55
56impl Default for LinkRecipe {
57 fn default() -> Self {
58 Self {
59 corner_radius: LINK_CORNER_RADIUS,
60 underline_thickness: LINK_UNDERLINE_THICKNESS,
61 }
62 }
63}
64
65/// Default `LinkStyle` shipped with Teksilo.
66#[derive(Debug, Default, Clone, Copy)]
67pub struct RecipeLinkStyle {
68 pub recipe: LinkRecipe,
69}
70
71impl RecipeLinkStyle {
72 pub fn new(recipe: LinkRecipe) -> Self {
73 Self { recipe }
74 }
75}
76
77impl LinkStyle for RecipeLinkStyle {
78 fn make_body(&self, cfg: &LinkStyleConfig, ctx: &mut BuildContext) -> WidgetId {
79 // Derived `Signal<TextRole>` combining the four state signals
80 // plus the reactive disabled signal. The text widget and the
81 // underline both bind to this role, so the underline tracks
82 // the text colour through every state transition.
83 //
84 // Note: `is_disabled` is now reactive (a `Signal<bool>` sourced
85 // from the arena's `effective_enabled` chain). At
86 // wide-enough wrap widths, the leaves' `ColorProp::resolve`
87 // would already substitute `TextRole::Disabled` when
88 // `effective_enabled = false` — but the link style explicitly
89 // returns a `Disabled` role here too so the underline path
90 // and any test/snapshot consuming `LinkStyleConfig.is_disabled`
91 // stay coherent.
92 let text_role: Signal<TextRole> = cfg
93 .is_hovered
94 .zip3(&cfg.is_pressed, &cfg.is_focused)
95 .zip(&cfg.is_visited)
96 .zip(&cfg.is_disabled)
97 .map(move |(((h, p, f), v), d)| link_text_role(*h, *p, *f, *v, *d));
98
99 let text_id = ctx.add(
100 TextWidget::new(teksilo_i18n::lit!(""))
101 .text(cfg.text.clone())
102 .style(TextStyleRole::Body)
103 .color(text_role.clone())
104 .single_line()
105 .a11y_hidden(),
106 );
107
108 // 1 px underline below the text, bound to the same text-role
109 // signal as the background colour so the line matches.
110 let underline = ctx.add(RectWidget::new().background(text_role));
111 let underline_sized = ctx.add(
112 FixedSize::new()
113 .height(self.recipe.underline_thickness)
114 .child_id(underline),
115 );
116
117 let content_id = ctx.add(
118 VStack::new()
119 .spacing(0.0)
120 .add_child(text_id)
121 .add_child(underline_sized),
122 );
123
124 // Focus ring — accent border drawn only when the link holds
125 // keyboard focus. IntUI convention paints the ring on the link
126 // itself (not as a separate outline) so the focus envelope
127 // matches the rounded text bounds.
128 let focus_ring_width = ctx.theme().shape.focus_ring_width;
129 let focus_border_role = cfg.is_focused.map(|f| {
130 if *f {
131 BorderRole::Focused
132 } else {
133 BorderRole::Transparent
134 }
135 });
136 let focus_border_width = cfg
137 .is_focused
138 .map(move |f| if *f { focus_ring_width } else { 0.0 });
139 let focus_rect_id = ctx.add(
140 RectWidget::new()
141 .border_color(focus_border_role)
142 .border_width(focus_border_width)
143 .corner_radius(CornerRadius::uniform(self.recipe.corner_radius)),
144 );
145
146 ctx.add(ZStack::new().add_child(focus_rect_id).add_child(content_id))
147 }
148}