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, InputTokens, 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 LinkRecipe {
57 /// This recipe's dimensions resolved against a density's [`InputTokens`].
58 ///
59 /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
60 /// ladder — so the shipped values below are the Compact column by
61 /// construction and cannot drift from it.
62 ///
63 /// Every dimension this recipe carries is a decoration (a corner radius, a
64 /// hairline, a glyph metric), so the parameter is unused: the density
65 /// ladder never moves any of them. It is taken all the same, so every
66 /// recipe is constructed the same way at its widget's build site.
67 pub fn for_tokens(_tokens: &InputTokens) -> Self {
68 Self {
69 corner_radius: LINK_CORNER_RADIUS,
70 underline_thickness: LINK_UNDERLINE_THICKNESS,
71 }
72 }
73}
74
75impl Default for LinkRecipe {
76 fn default() -> Self {
77 Self::for_tokens(&InputTokens::default())
78 }
79}
80
81/// Default `LinkStyle` shipped with Teksilo.
82#[derive(Debug, Default, Clone, Copy)]
83pub struct RecipeLinkStyle {
84 pub recipe: LinkRecipe,
85}
86
87impl RecipeLinkStyle {
88 pub fn new(recipe: LinkRecipe) -> Self {
89 Self { recipe }
90 }
91
92 /// This style with every dimension resolved against a density's
93 /// [`InputTokens`], as `RecipeLinkStyle::for_tokens(&ctx.theme().input)` at
94 /// the widget's own build site.
95 ///
96 /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
97 /// tree gets exactly the values this module documents.
98 pub fn for_tokens(tokens: &InputTokens) -> Self {
99 Self {
100 recipe: LinkRecipe::for_tokens(tokens),
101 }
102 }
103}
104
105impl LinkStyle for RecipeLinkStyle {
106 fn make_body(&self, cfg: &LinkStyleConfig, ctx: &mut BuildContext) -> WidgetId {
107 // Derived `Signal<TextRole>` combining the four state signals
108 // plus the reactive disabled signal. The text widget and the
109 // underline both bind to this role, so the underline tracks
110 // the text colour through every state transition.
111 //
112 // Note: `is_disabled` is now reactive (a `Signal<bool>` sourced
113 // from the arena's `effective_enabled` chain). At
114 // wide-enough wrap widths, the leaves' `ColorProp::resolve`
115 // would already substitute `TextRole::Disabled` when
116 // `effective_enabled = false` — but the link style explicitly
117 // returns a `Disabled` role here too so the underline path
118 // and any test/snapshot consuming `LinkStyleConfig.is_disabled`
119 // stay coherent.
120 let text_role: Signal<TextRole> = cfg
121 .is_hovered
122 .zip3(&cfg.is_pressed, &cfg.is_focused)
123 .zip(&cfg.is_visited)
124 .zip(&cfg.is_disabled)
125 .map(move |(((h, p, f), v), d)| link_text_role(*h, *p, *f, *v, *d));
126
127 let text_id = ctx.add(
128 TextWidget::new(teksilo_i18n::lit!(""))
129 .text(cfg.text.clone())
130 .style(TextStyleRole::Body)
131 .color(text_role.clone())
132 .single_line()
133 .a11y_hidden(),
134 );
135
136 // 1 px underline below the text, bound to the same text-role
137 // signal as the background colour so the line matches.
138 let underline = ctx.add(RectWidget::new().background(text_role));
139 let underline_sized = ctx.add(
140 FixedSize::new()
141 .height(self.recipe.underline_thickness)
142 .child(underline),
143 );
144
145 let content_id = ctx.add(
146 VStack::new()
147 .spacing(0.0)
148 .child(text_id)
149 .child(underline_sized),
150 );
151
152 // Focus ring — accent border drawn only when the link holds
153 // keyboard focus. IntUI convention paints the ring on the link
154 // itself (not as a separate outline) so the focus envelope
155 // matches the rounded text bounds.
156 let focus_ring_width = ctx.theme().shape.focus_ring_width;
157 let focus_border_role = cfg.is_focused.map(|f| {
158 if *f {
159 BorderRole::Focused
160 } else {
161 BorderRole::Transparent
162 }
163 });
164 let focus_border_width = cfg
165 .is_focused
166 .map(move |f| if *f { focus_ring_width } else { 0.0 });
167 let focus_rect_id = ctx.add(
168 RectWidget::new()
169 .border_color(focus_border_role)
170 .border_width(focus_border_width)
171 .corner_radius(CornerRadius::uniform(self.recipe.corner_radius)),
172 );
173
174 ctx.add(ZStack::new().child(focus_rect_id).child(content_id))
175 }
176}