Skip to main content

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}