use smallvec::SmallVec;
use crate::clock::FrameInstant;
use crate::finite::{FiniteOr, first_finite_override};
use crate::jobs::RequiredJob;
use crate::reactive::{IntoSignal, Prop};
use crate::tree::WidgetId;
use crate::widgets::container::AnimationState;
use super::font::{FontFamily, FontWeight, LineHeight};
use super::widget::Color;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextStroke {
pub width: f32,
pub color: Color,
}
impl TextStroke {
pub fn new(width: f32, color: Color) -> Self {
Self { width, color }
}
pub(crate) fn samples(&self) -> SmallVec<[(f32, f32); 24]> {
let taps = (self.width * 6.0).clamp(8.0, 24.0) as usize;
(0..taps)
.map(|i| {
let angle = std::f32::consts::TAU * i as f32 / taps as f32;
(self.width * angle.cos(), self.width * angle.sin())
})
.collect()
}
}
const SAMPLE_SPACING: f32 = 2.0;
const SAMPLE_BUDGET: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextShadow {
pub offset: (f32, f32),
pub blur: f32,
pub color: Color,
}
impl TextShadow {
pub fn new(offset_x: f32, offset_y: f32, blur: f32, color: Color) -> Self {
Self {
offset: (offset_x, offset_y),
blur,
color,
}
}
pub(crate) fn samples(&self) -> SmallVec<[(f32, f32, Color); 32]> {
let (ox, oy) = self.offset;
let mut out = SmallVec::new();
if self.blur <= 0.0 {
out.push((ox, oy, self.color));
return out;
}
let spacing =
SAMPLE_SPACING.max(self.blur * (std::f32::consts::PI / SAMPLE_BUDGET as f32).sqrt());
let rings = (self.blur / spacing).ceil().max(1.0);
for ring in (1..=rings as usize).rev() {
let radius = self.blur * ring as f32 / rings;
let taps = ((std::f32::consts::TAU * radius / spacing).ceil() as usize).max(8);
let color = self.color.with_alpha(self.ring_alpha(radius, rings));
for i in 0..taps {
let angle = std::f32::consts::TAU * i as f32 / taps as f32;
out.push((ox + radius * angle.cos(), oy + radius * angle.sin(), color));
}
}
out.push((ox, oy, self.color));
out
}
fn ring_alpha(&self, radius: f32, rings: f32) -> f32 {
const PEAK: f32 = 0.6;
let t = radius / self.blur;
(self.color.a * PEAK / rings.sqrt() * (-2.0 * t * t).exp()).min(self.color.a)
}
}
pub(crate) const DEFAULT_FONT_SIZE: f32 = 14.0;
#[derive(Clone, Copy, Default, PartialEq)]
pub struct TextStyle {
pub color: Prop<Color>,
pub font_size: Prop<f32>,
pub font_family: Prop<FontFamily>,
pub font_weight: Prop<FontWeight>,
pub line_height: Prop<LineHeight>,
pub letter_spacing: Prop<f32>,
pub stroke: Prop<TextStroke>,
pub shadow: Prop<TextShadow>,
}
#[derive(Default)]
pub(crate) struct ResolvedTextStyle {
color_overrides: SmallVec<[Prop<Color>; 3]>,
font_size_overrides: SmallVec<[Prop<f32>; 3]>,
letter_spacing_overrides: SmallVec<[Prop<f32>; 3]>,
color: Prop<Color>,
font_size: Prop<f32>,
letter_spacing: Prop<f32>,
font_family: Prop<FontFamily>,
font_weight: Prop<FontWeight>,
line_height: Prop<LineHeight>,
stroke: Prop<TextStroke>,
shadow: Prop<TextShadow>,
}
impl ResolvedTextStyle {
pub(crate) fn push_override(&mut self, style: &TextStyle) {
if style.color.is_set() {
self.color_overrides.push(style.color);
}
if style.font_size.is_set() {
self.font_size_overrides.push(style.font_size);
}
if style.letter_spacing.is_set() {
self.letter_spacing_overrides.push(style.letter_spacing);
}
self.take_unset(style);
}
pub(crate) fn push_own(&mut self, style: &TextStyle) {
self.color = style.color;
self.font_size = style.font_size;
self.letter_spacing = style.letter_spacing;
self.take_unset(style);
}
fn take_unset(&mut self, style: &TextStyle) {
self.font_family = self.font_family.or(style.font_family);
self.font_weight = self.font_weight.or(style.font_weight);
self.line_height = self.line_height.or(style.line_height);
self.stroke = self.stroke.or(style.stroke);
self.shadow = self.shadow.or(style.shadow);
}
pub(crate) fn color(&self, id: WidgetId) -> Color {
let base = self.color.get_finite_or(Color::WHITE, id, "color");
first_finite_override(&self.color_overrides, base)
}
pub(crate) fn font_size(&self, id: WidgetId) -> f32 {
let base = self
.font_size
.get_finite_or(DEFAULT_FONT_SIZE, id, "font_size");
first_finite_override(&self.font_size_overrides, base)
}
pub(crate) fn letter_spacing(&self, id: WidgetId) -> f32 {
let base = self.letter_spacing.get_finite_or(0.0, id, "letter_spacing");
first_finite_override(&self.letter_spacing_overrides, base) + 0.0
}
pub(crate) fn font_family(&self) -> FontFamily {
self.font_family
.get()
.unwrap_or_else(crate::default_font_family)
}
pub(crate) fn font_weight(&self) -> FontWeight {
self.font_weight.get_or(FontWeight::NORMAL)
}
pub(crate) fn line_height(&self) -> LineHeight {
self.line_height.get_or(LineHeight::Normal)
}
pub(crate) fn stroke(&self) -> Option<TextStroke> {
self.stroke.get()
}
pub(crate) fn shadow(&self) -> Option<TextShadow> {
self.shadow.get()
}
}
#[derive(Default)]
pub(crate) struct TextAnims {
pub(crate) color: Option<AnimationState<Color>>,
pub(crate) font_size: Option<AnimationState<f32>>,
}
impl TextAnims {
pub(crate) fn retarget(
&mut self,
ctx: &mut crate::tree::LayoutCtx,
color: Color,
size: f32,
now: FrameInstant,
) -> (Option<RequiredJob>, f32) {
let mut wants = None;
if let Some(a) = self.color.as_mut() {
let play = a.take_play();
if a.is_initial() {
if !a.begin_enter(ctx, color, || now) {
a.set_immediate(ctx, color);
}
if play {
a.play(now);
}
} else {
if play {
a.play(now);
}
a.animate_to(color, now);
}
if a.is_animating() {
wants = Some(RequiredJob::Paint);
}
}
let mut measured = size;
if let Some(a) = self.font_size.as_mut() {
let play = a.take_play();
if a.is_initial() {
if !a.begin_enter(ctx, size, || now) {
a.set_immediate(ctx, size);
}
if play {
a.play(now);
}
} else {
if play {
a.play(now);
}
a.animate_to(size, now);
}
measured = a.displayed_in(ctx);
if a.is_animating() {
wants = Some(RequiredJob::Layout);
}
}
(wants, measured)
}
pub(crate) fn advance(&mut self, now: FrameInstant) -> Option<RequiredJob> {
let mut wants = None;
if let Some(a) = self.color.as_mut()
&& a.advance(now).is_changed()
{
wants = Some(RequiredJob::Paint);
}
if let Some(a) = self.font_size.as_mut()
&& a.advance(now).is_changed()
{
wants = Some(RequiredJob::Layout);
}
wants
}
pub(crate) fn is_animating(&self) -> bool {
self.color.as_ref().is_some_and(|a| a.is_animating())
|| self.font_size.as_ref().is_some_and(|a| a.is_animating())
}
pub(crate) fn animates_color(&self) -> bool {
self.color.is_some()
}
}
macro_rules! declares_text_style {
($widget:ty, $style:ident, $anims:ident) => {
$crate::widgets::text_style::declares_text_style!(impl[] $widget, $style, $anims);
};
(impl[$($generics:tt)*] $widget:ty, $style:ident, $anims:ident) => {
impl<$($generics)*> $widget {
fn text_style_mut(&mut self) -> &mut $crate::widgets::text_style::TextStyle {
self.$style.get_or_insert_with(Default::default)
}
fn resolved_text_style(
&self,
tree: &$crate::tree::Tree,
id: $crate::tree::WidgetId,
) -> $crate::widgets::text_style::ResolvedTextStyle {
let mut style = $crate::widgets::text_style::ResolvedTextStyle::default();
if !self.states.is_empty() {
let control = tree.nearest_control(id);
for (when, override_) in self.states.iter().rev() {
if self.is_state_active(id, control.as_ref(), when) {
style.push_override(override_);
}
}
}
if let Some(own) = self.$style.as_deref() {
style.push_own(own);
}
style
}
fn retarget_text_anims(
&mut self,
ctx: &mut $crate::tree::LayoutCtx,
id: $crate::tree::WidgetId,
color: $crate::widgets::Color,
size: f32,
) -> f32 {
let Some(anims) = self.$anims.as_deref_mut() else {
return size;
};
let now = ctx.tree_ref().frame_instant();
let (wants, measured) = anims.retarget(ctx, color, size, now);
if let Some(required) = wants {
$crate::jobs::request_job(id, $crate::jobs::JobRequest::Animation(required));
}
measured
}
fn advance_text_anims(
&mut self,
tree: &$crate::tree::Tree,
id: $crate::tree::WidgetId,
) -> bool {
let now = tree.frame_instant();
let Some(anims) = self.$anims.as_deref_mut() else {
return false;
};
let moved = anims.advance(now);
let running = anims.is_animating();
if let Some(required) = moved {
$crate::jobs::request_job(id, $crate::jobs::JobRequest::Animation(required));
} else if running {
$crate::jobs::request_job(
id,
$crate::jobs::JobRequest::Animation($crate::jobs::RequiredJob::None),
);
}
running
}
fn animates_text_color(&self) -> bool {
self.$anims
.as_deref()
.is_some_and($crate::widgets::text_style::TextAnims::animates_color)
}
pub fn color<M>(
mut self,
color: impl $crate::animation::IntoAnimated<$crate::widgets::Color, M>,
) -> Self {
let declared = $crate::widgets::container::declare(
&mut self.$anims,
color,
|a: &mut $crate::widgets::text_style::TextAnims| &mut a.color,
);
self.text_style_mut().color = declared;
self
}
pub fn font_size<M>(
mut self,
size: impl $crate::animation::IntoAnimated<f32, M>,
) -> Self {
let declared = $crate::widgets::container::declare(
&mut self.$anims,
size,
|a: &mut $crate::widgets::text_style::TextAnims| &mut a.font_size,
);
self.text_style_mut().font_size = declared;
self
}
pub fn font_family<M>(
mut self,
family: impl $crate::reactive::IntoSignal<$crate::widgets::FontFamily, M>,
) -> Self {
self.text_style_mut().font_family = family.into_prop();
self
}
pub fn font_weight<M>(
mut self,
weight: impl $crate::reactive::IntoSignal<$crate::widgets::FontWeight, M>,
) -> Self {
self.text_style_mut().font_weight = weight.into_prop();
self
}
pub fn bold(self) -> Self {
self.font_weight($crate::widgets::FontWeight::BOLD)
}
pub fn line_height<M>(
mut self,
height: impl $crate::reactive::IntoSignal<$crate::widgets::LineHeight, M>,
) -> Self {
self.text_style_mut().line_height = height.into_prop();
self
}
pub fn mono(self) -> Self {
self.font_family($crate::widgets::FontFamily::Monospace)
}
pub fn letter_spacing<M>(
mut self,
spacing: impl $crate::reactive::IntoSignal<f32, M>,
) -> Self {
self.text_style_mut().letter_spacing = spacing.into_prop();
self
}
pub fn text_stroke<M>(
mut self,
stroke: impl $crate::reactive::IntoSignal<$crate::widgets::TextStroke, M>,
) -> Self {
self.text_style_mut().stroke = stroke.into_prop();
self
}
pub fn text_shadow<M>(
mut self,
shadow: impl $crate::reactive::IntoSignal<$crate::widgets::TextShadow, M>,
) -> Self {
self.text_style_mut().shadow = shadow.into_prop();
self
}
}
};
}
pub(crate) use declares_text_style;
impl TextStyle {
pub fn color<M>(mut self, color: impl IntoSignal<Color, M>) -> Self {
self.color = color.into_prop();
self
}
pub fn font_size<M>(mut self, size: impl IntoSignal<f32, M>) -> Self {
self.font_size = size.into_prop();
self
}
pub fn font_family<M>(mut self, family: impl IntoSignal<FontFamily, M>) -> Self {
self.font_family = family.into_prop();
self
}
pub fn font_weight<M>(mut self, weight: impl IntoSignal<FontWeight, M>) -> Self {
self.font_weight = weight.into_prop();
self
}
pub fn bold(self) -> Self {
self.font_weight(FontWeight::BOLD)
}
pub fn line_height<M>(mut self, height: impl IntoSignal<LineHeight, M>) -> Self {
self.line_height = height.into_prop();
self
}
pub fn mono(self) -> Self {
self.font_family(FontFamily::Monospace)
}
pub fn letter_spacing<M>(mut self, spacing: impl IntoSignal<f32, M>) -> Self {
self.letter_spacing = spacing.into_prop();
self
}
pub fn text_stroke<M>(mut self, stroke: impl IntoSignal<TextStroke, M>) -> Self {
self.stroke = stroke.into_prop();
self
}
pub fn text_shadow<M>(mut self, shadow: impl IntoSignal<TextShadow, M>) -> Self {
self.shadow = shadow.into_prop();
self
}
}
#[cfg(test)]
mod a_constant_text_property_is_a_constant {
use super::*;
use crate::reactive::storage::slot_count;
use crate::widgets::text;
fn slots(what: impl Fn()) -> usize {
what();
let mark = slot_count();
what();
slot_count() - mark
}
#[test]
fn a_named_family_claims_no_slot_where_a_closure_claims_one() {
let constant = slots(|| drop(text("x").font_family(FontFamily::name("Inter"))));
let reactive = slots(|| drop(text("x").font_family(|| FontFamily::name("Inter"))));
assert_eq!(
constant,
reactive - 1,
"a named family must claim no slot and a closure must claim one — \
got {constant} against {reactive}"
);
}
#[test]
fn a_generic_family_costs_what_declaring_none_costs() {
drop(text("x").font_family(FontFamily::Monospace));
let mark = slot_count();
drop(text("x"));
let bare = slot_count() - mark;
let mark = slot_count();
drop(text("x").font_family(FontFamily::Monospace));
let named = slot_count() - mark;
let mark = slot_count();
drop(text("x").mono());
let shorthand = slot_count() - mark;
assert_eq!(
named, bare,
"`Monospace` is a discriminant, not a value to keep"
);
assert_eq!(
shorthand, bare,
"and `mono()` is the same declaration spelled shorter"
);
}
}