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