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, 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}