fenestra_core/style.rs
1//! The fully-typed style IR. Three groups: layout (maps 1:1 onto taffy),
2//! paint, and text. No CSS strings anywhere; every property autocompletes.
3
4use peniko::Color;
5
6use crate::tokens::{FamilyRole, TextSize, Weight};
7
8/// A length value for sizes and flex basis.
9#[derive(Debug, Clone, Copy, PartialEq, Default)]
10pub enum Length {
11 /// Logical pixels.
12 Px(f32),
13 /// Percent of the parent, 0.0..=100.0.
14 Pct(f32),
15 /// A reading measure in CSS `ch` units: 1ch is the advance width of the
16 /// digit `'0'` in the element's own resolved text style. Used to cap a
17 /// prose column near the optimal line length (the default
18 /// [`MEASURE_CH`](crate::MEASURE_CH) is calibrated for ~66 characters)
19 /// independent of window width. Resolved to `Px` during layout, where font
20 /// metrics are available; treat it as `Auto` if it ever reaches taffy
21 /// unresolved.
22 Ch(f32),
23 /// Let layout decide.
24 #[default]
25 Auto,
26}
27
28impl Length {
29 /// Resolves a `Ch(n)` to `Px(n * ch_px)`; other variants pass through.
30 pub(crate) fn resolved(self, ch_px: f32) -> Length {
31 match self {
32 Length::Ch(n) => Length::Px(n * ch_px),
33 other => other,
34 }
35 }
36}
37
38/// Display mode of a box.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum Display {
41 /// Flexbox container (the default).
42 #[default]
43 Flex,
44 /// Grid container.
45 Grid,
46 /// Removed from layout entirely.
47 None,
48}
49
50/// Main axis direction of a flex container.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum Direction {
53 /// Left to right.
54 #[default]
55 Row,
56 /// Top to bottom.
57 Column,
58}
59
60/// Cross-axis alignment of children.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum AlignItems {
63 /// Stretch to fill the cross axis (CSS default).
64 #[default]
65 Stretch,
66 /// Pack toward the start.
67 Start,
68 /// Center.
69 Center,
70 /// Pack toward the end.
71 End,
72 /// Align children on their first text baseline (rows only). Boxes
73 /// without text use their bottom edge, like CSS synthesized baselines.
74 Baseline,
75}
76
77/// Main-axis distribution of children.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum JustifyContent {
80 /// Pack toward the start (the default).
81 #[default]
82 Start,
83 /// Center.
84 Center,
85 /// Pack toward the end.
86 End,
87 /// Distribute with space between items.
88 SpaceBetween,
89}
90
91/// Multi-line content alignment (flex wrap / grid).
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum AlignContent {
94 /// Pack lines toward the start.
95 #[default]
96 Start,
97 /// Center lines.
98 Center,
99 /// Pack lines toward the end.
100 End,
101 /// Stretch lines to fill.
102 Stretch,
103 /// Distribute with space between lines.
104 SpaceBetween,
105}
106
107/// Positioning scheme.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum Position {
110 /// Normal flow; inset offsets visually only.
111 #[default]
112 Relative,
113 /// Out of flow, positioned against the nearest relative ancestor.
114 Absolute,
115 /// In normal flow until the nearest scroll container scrolls it past a
116 /// `sticky_*` threshold, then pinned to that edge of the viewport (CSS
117 /// `position: sticky`). Resolved post-layout in the realize pass.
118 Sticky,
119}
120
121/// Overflow behavior per axis.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum Overflow {
124 /// Content may paint outside the box.
125 #[default]
126 Visible,
127 /// Content is clipped to the box.
128 Hidden,
129 /// Content is clipped and the box scrolls (M3).
130 Scroll,
131}
132
133/// Per-edge values (padding, margin, inset).
134#[derive(Debug, Clone, Copy, PartialEq, Default)]
135pub struct Edges {
136 /// Top edge.
137 pub top: f32,
138 /// Right edge.
139 pub right: f32,
140 /// Bottom edge.
141 pub bottom: f32,
142 /// Left edge.
143 pub left: f32,
144}
145
146impl Edges {
147 /// The same value on all four edges.
148 pub const fn all(v: f32) -> Self {
149 Self {
150 top: v,
151 right: v,
152 bottom: v,
153 left: v,
154 }
155 }
156}
157
158/// Optional per-edge offsets for positioned elements.
159#[derive(Debug, Clone, Copy, PartialEq, Default)]
160pub struct Inset {
161 /// Offset from the top.
162 pub top: Option<f32>,
163 /// Offset from the right.
164 pub right: Option<f32>,
165 /// Offset from the bottom.
166 pub bottom: Option<f32>,
167 /// Offset from the left.
168 pub left: Option<f32>,
169}
170
171/// A grid track size — CSS `<track-size>`. `Px` and `Fr` are the common cases;
172/// `MinMax` plus the content keywords cover responsive templates.
173#[derive(Debug, Clone, Copy, PartialEq)]
174pub enum Track {
175 /// Fixed logical pixels.
176 Px(f32),
177 /// Fraction of remaining free space (CSS `fr`).
178 Fr(f32),
179 /// Sized to fit its content within the available space (CSS `auto`).
180 Auto,
181 /// The largest minimal content contribution (CSS `min-content`).
182 MinContent,
183 /// The largest maximal content contribution (CSS `max-content`).
184 MaxContent,
185 /// `fit-content(px)`: like `auto`, but capped at the given pixels.
186 FitContent(f32),
187 /// `minmax(min, max)`: a floor and a ceiling for the track size.
188 MinMax(TrackMin, TrackMax),
189}
190
191/// The `min` argument of a [`Track::MinMax`] — a track's floor. A floor cannot be
192/// flexible, so there is no `fr` here (CSS forbids it).
193#[derive(Debug, Clone, Copy, PartialEq)]
194pub enum TrackMin {
195 /// Fixed logical pixels.
196 Px(f32),
197 /// `auto` (the track's minimum content size).
198 Auto,
199 /// `min-content`.
200 MinContent,
201 /// `max-content`.
202 MaxContent,
203}
204
205/// The `max` argument of a [`Track::MinMax`] — a track's ceiling.
206#[derive(Debug, Clone, Copy, PartialEq)]
207pub enum TrackMax {
208 /// Fixed logical pixels.
209 Px(f32),
210 /// Fraction of remaining free space (CSS `fr`).
211 Fr(f32),
212 /// `auto`.
213 Auto,
214 /// `min-content`.
215 MinContent,
216 /// `max-content`.
217 MaxContent,
218 /// `fit-content(px)`.
219 FitContent(f32),
220}
221
222/// One entry of a grid template: a single [`Track`], or a `repeat(...)` of tracks
223/// (including the responsive `auto-fit` / `auto-fill`). Build a `Vec<GridTemplate>`
224/// with [`Style::grid_cols`] / [`Style::grid_rows`], which also accept plain
225/// `Track`s (each wrapped as [`GridTemplate::Single`]).
226#[derive(Debug, Clone, PartialEq)]
227pub enum GridTemplate {
228 /// A single track.
229 Single(Track),
230 /// `repeat(count, tracks)` — the fragment `tracks` repeated per [`Repeat`].
231 Repeat(Repeat, Vec<Track>),
232}
233
234/// How many times a `repeat(...)` fragment is generated.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum Repeat {
237 /// Exactly `n` repetitions.
238 Count(u16),
239 /// As many as fit, collapsing empty repetitions (CSS `auto-fit`).
240 AutoFit,
241 /// As many as fit, keeping empty repetitions (CSS `auto-fill`).
242 AutoFill,
243}
244
245impl GridTemplate {
246 /// `repeat(auto-fit, minmax(min_px, 1fr))` — the canonical responsive grid:
247 /// as many equal columns as fit, each at least `min_px` wide, collapsing
248 /// empty tracks so the row stays centered when it is not full.
249 #[must_use]
250 pub fn auto_fit_minmax(min_px: f32) -> Self {
251 Self::Repeat(
252 Repeat::AutoFit,
253 vec![Track::MinMax(TrackMin::Px(min_px), TrackMax::Fr(1.0))],
254 )
255 }
256
257 /// `repeat(auto-fill, minmax(min_px, 1fr))` — like [`Self::auto_fit_minmax`],
258 /// but empty trailing tracks keep their space (CSS `auto-fill`).
259 #[must_use]
260 pub fn auto_fill_minmax(min_px: f32) -> Self {
261 Self::Repeat(
262 Repeat::AutoFill,
263 vec![Track::MinMax(TrackMin::Px(min_px), TrackMax::Fr(1.0))],
264 )
265 }
266
267 /// `repeat(n, tracks)`.
268 #[must_use]
269 pub fn repeat(n: u16, tracks: impl IntoIterator<Item = Track>) -> Self {
270 Self::Repeat(Repeat::Count(n), tracks.into_iter().collect())
271 }
272}
273
274impl From<Track> for GridTemplate {
275 fn from(t: Track) -> Self {
276 Self::Single(t)
277 }
278}
279
280/// Grid item placement on one axis.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
282pub struct GridPlace {
283 /// 1-based start line; `None` for auto.
284 pub start: Option<i16>,
285 /// Number of tracks to span; `None` for 1 (or auto).
286 pub span: Option<u16>,
287}
288
289/// A named-line placement on one grid axis: the start and end line names
290/// (CSS `grid-column: a / b`). Empty by default — numeric/auto placement. Names
291/// resolve against the parent grid's `grid-template-areas` (`<name>-start` /
292/// `<name>-end`) and explicit line names.
293#[derive(Debug, Clone, PartialEq, Eq, Default)]
294pub struct GridLines {
295 /// Start line name (before the `/`).
296 pub start: Option<String>,
297 /// End line name (after the `/`).
298 pub end: Option<String>,
299}
300
301/// A gradient color stop: offset 0.0..=1.0 and a color.
302#[derive(Debug, Clone, Copy, PartialEq)]
303pub struct GradientStop {
304 /// Position along the gradient, 0.0..=1.0.
305 pub offset: f32,
306 /// Color at this stop.
307 pub color: Color,
308}
309
310/// Background paint.
311#[derive(Debug, Clone, PartialEq)]
312pub enum Paint {
313 /// A solid color.
314 Solid(Color),
315 /// A linear gradient at a CSS-style angle in degrees: 0 points up,
316 /// 90 points right. Endpoints are computed from the element's rect.
317 LinearGradient {
318 /// CSS-style angle in degrees.
319 angle_deg: f32,
320 /// Color stops, offsets 0.0..=1.0.
321 stops: Vec<GradientStop>,
322 },
323 /// A radial gradient centered at `center` (unit coordinates within the
324 /// element rect) reaching `radius` times half the rect diagonal.
325 RadialGradient {
326 /// Center in unit coordinates (0.5, 0.5 is the middle).
327 center: (f32, f32),
328 /// Radius as a multiple of half the rect's larger side.
329 radius: f32,
330 /// Color stops, offsets 0.0..=1.0.
331 stops: Vec<GradientStop>,
332 },
333 /// A conic (sweep) gradient centered at `center` (unit coordinates within
334 /// the element rect), sweeping the stops once around the full circle.
335 ConicGradient {
336 /// Center in unit coordinates (0.5, 0.5 is the middle).
337 center: (f32, f32),
338 /// Color stops, offsets 0.0..=1.0 mapped around the sweep.
339 stops: Vec<GradientStop>,
340 },
341}
342
343impl From<Color> for Paint {
344 fn from(c: Color) -> Self {
345 Self::Solid(c)
346 }
347}
348
349/// Expands OKLCH-interpolated color stops between anchor colors. Each adjacent
350/// anchor pair is walked in `steps` sub-segments through OKLCH (shortest hue
351/// arc, achromatic-endpoint handling, gamut-clamped — the exact OKLCH lerp the
352/// transition engine animates colors along), so the rendered ramp stays
353/// perceptually even with no desaturated "gray dead-zone" through the middle of
354/// a wide-hue transition. The vello renderer interpolates the returned stops in
355/// sRGB, but they sit densely on the OKLCH curve, so the on-screen ramp tracks
356/// it.
357/// Anchors are `(offset, color)` with offsets in `0.0..=1.0`; they are sorted
358/// ascending and the endpoints are preserved exactly. Colors must come from
359/// theme tokens or [`oklch`](crate::oklch) / [`oklch_of`](crate::oklch_of) —
360/// never a raw hex literal.
361///
362/// Edge cases: empty anchors yield `vec![]`; a single anchor yields one stop at
363/// its offset; `steps == 0` yields the (sorted) anchors verbatim, un-interpolated.
364#[must_use]
365pub fn oklch_stops(anchors: &[(f32, Color)], steps: usize) -> Vec<GradientStop> {
366 if anchors.is_empty() {
367 return Vec::new();
368 }
369 let mut sorted = anchors.to_vec();
370 sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
371 // A single anchor (or no sub-segments requested) has nothing to walk
372 // through; emit the anchors verbatim.
373 if sorted.len() == 1 || steps == 0 {
374 return sorted
375 .iter()
376 .map(|&(offset, color)| GradientStop { offset, color })
377 .collect();
378 }
379 let mut out = Vec::with_capacity((sorted.len() - 1) * steps + 1);
380 for (seg, pair) in sorted.windows(2).enumerate() {
381 let (o0, c0) = pair[0];
382 let (o1, c1) = pair[1];
383 // The shared boundary stop is emitted once: skip j == 0 on every
384 // segment after the first.
385 let first = usize::from(seg != 0);
386 for j in first..=steps {
387 #[expect(clippy::cast_precision_loss, reason = "gradient step counts are tiny")]
388 let t = j as f32 / steps as f32;
389 out.push(GradientStop {
390 offset: o0 + (o1 - o0) * t,
391 color: crate::anim::lerp_color(c0, c1, t),
392 });
393 }
394 }
395 out
396}
397
398/// Spaces `colors` evenly across `0.0..=1.0` as `(offset, color)` anchors. One
399/// color anchors at 0.0; the empty list yields no anchors.
400fn even_anchors(colors: impl IntoIterator<Item = Color>) -> Vec<(f32, Color)> {
401 let colors: Vec<Color> = colors.into_iter().collect();
402 let last = colors.len().saturating_sub(1);
403 if last == 0 {
404 return colors.into_iter().map(|c| (0.0, c)).collect();
405 }
406 colors
407 .into_iter()
408 .enumerate()
409 .map(|(i, c)| {
410 #[expect(clippy::cast_precision_loss, reason = "color counts are tiny")]
411 let offset = i as f32 / last as f32;
412 (offset, c)
413 })
414 .collect()
415}
416
417/// A gradient needs at least two colors to interpolate; fewer collapses to a
418/// solid fill (the lone color, or fully transparent for none) so the painter
419/// never receives a degenerate one-or-zero-stop list.
420fn degenerate_solid(colors: &[Color]) -> Option<Paint> {
421 match colors {
422 [] => Some(Paint::Solid(Color::new([0.0, 0.0, 0.0, 0.0]))),
423 [c] => Some(Paint::Solid(*c)),
424 _ => None,
425 }
426}
427
428/// A linear [`Paint`] whose `colors` are spaced evenly across `0.0..=1.0` and
429/// expanded into a perceptually smooth OKLCH ramp ([`oklch_stops`] with
430/// [`GRADIENT_STEPS`](crate::GRADIENT_STEPS)). `angle_deg` is CSS-style (0 up,
431/// 90 right). Reads from tokens, e.g.
432/// `bg(linear_gradient(135.0, [t.accent, t.accent_text]))`. Fewer than two
433/// colors collapse to a solid fill (one color, or transparent for none).
434#[must_use]
435pub fn linear_gradient(angle_deg: f32, colors: impl IntoIterator<Item = Color>) -> Paint {
436 let colors: Vec<Color> = colors.into_iter().collect();
437 degenerate_solid(&colors).unwrap_or_else(|| Paint::LinearGradient {
438 angle_deg,
439 stops: oklch_stops(&even_anchors(colors), crate::tokens::GRADIENT_STEPS),
440 })
441}
442
443/// A radial [`Paint`] (see [`Paint::RadialGradient`] for `center` / `radius`)
444/// whose `colors` are spaced evenly across `0.0..=1.0` and expanded into an
445/// OKLCH ramp ([`oklch_stops`] with [`GRADIENT_STEPS`](crate::GRADIENT_STEPS)).
446/// Fewer than two colors collapse to a solid fill (one color, or transparent).
447#[must_use]
448pub fn radial_gradient(
449 center: (f32, f32),
450 radius: f32,
451 colors: impl IntoIterator<Item = Color>,
452) -> Paint {
453 let colors: Vec<Color> = colors.into_iter().collect();
454 degenerate_solid(&colors).unwrap_or_else(|| Paint::RadialGradient {
455 center,
456 radius,
457 stops: oklch_stops(&even_anchors(colors), crate::tokens::GRADIENT_STEPS),
458 })
459}
460
461/// A conic (sweep) [`Paint`] (see [`Paint::ConicGradient`]) centered at `center`
462/// (unit coordinates within the element rect), sweeping `colors` once around the
463/// full circle as a smooth OKLCH ramp ([`oklch_stops`] with
464/// [`GRADIENT_STEPS`](crate::GRADIENT_STEPS)). Fewer than two colors collapse to
465/// a solid fill. Reads from theme tokens — never a raw hex literal.
466#[must_use]
467pub fn conic_gradient(center: (f32, f32), colors: impl IntoIterator<Item = Color>) -> Paint {
468 let colors: Vec<Color> = colors.into_iter().collect();
469 degenerate_solid(&colors).unwrap_or_else(|| Paint::ConicGradient {
470 center,
471 stops: oklch_stops(&even_anchors(colors), crate::tokens::GRADIENT_STEPS),
472 })
473}
474
475/// A border stroke: a width and color. Apply it to every edge with
476/// [`Style::border`], or to a single edge with [`Style::border_top`] and
477/// friends (carried per-edge by [`EdgeBorders`]).
478#[derive(Debug, Clone, Copy, PartialEq)]
479pub struct Border {
480 /// Stroke width in logical pixels.
481 pub width: f32,
482 /// Stroke color.
483 pub color: Color,
484}
485
486/// Per-edge border strokes (top/right/bottom/left), each optional and
487/// independent of the uniform [`Border`]. Drawn as straight hairlines with
488/// square corners — for a rounded full edge use [`Style::border`]. Lets
489/// hairline-divided layouts (a header's bottom rule, a left accent rail, a
490/// table's ruled rows) skip manual 1px divider children.
491#[derive(Debug, Clone, Copy, PartialEq, Default)]
492pub struct EdgeBorders {
493 /// Top edge stroke.
494 pub top: Option<Border>,
495 /// Right edge stroke.
496 pub right: Option<Border>,
497 /// Bottom edge stroke.
498 pub bottom: Option<Border>,
499 /// Left edge stroke.
500 pub left: Option<Border>,
501}
502
503/// Per-corner radii in logical pixels.
504#[derive(Debug, Clone, Copy, PartialEq, Default)]
505pub struct CornerRadius {
506 /// Top-left.
507 pub tl: f32,
508 /// Top-right.
509 pub tr: f32,
510 /// Bottom-right.
511 pub br: f32,
512 /// Bottom-left.
513 pub bl: f32,
514}
515
516impl CornerRadius {
517 /// The same radius on all corners.
518 pub const fn all(r: f32) -> Self {
519 Self {
520 tl: r,
521 tr: r,
522 br: r,
523 bl: r,
524 }
525 }
526}
527
528/// One drop shadow layer.
529#[derive(Debug, Clone, Copy, PartialEq)]
530pub struct Shadow {
531 /// Horizontal offset.
532 pub dx: f32,
533 /// Vertical offset.
534 pub dy: f32,
535 /// Blur radius (CSS semantics: gaussian std dev = blur / 2).
536 pub blur: f32,
537 /// Outset applied to the shadow rect before blurring.
538 pub spread: f32,
539 /// Shadow color (usually black at low alpha).
540 pub color: Color,
541}
542
543/// Horizontal text alignment.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
545pub enum TextAlign {
546 /// Align to the start (left in LTR).
547 #[default]
548 Start,
549 /// Center.
550 Center,
551 /// Align to the end.
552 End,
553}
554
555/// How text is broken into lines once shaping has wrapped it at the
556/// available width. parley line-breaks greedily (each line is filled as
557/// full as it can be); these modes refine that result by re-wrapping at a
558/// narrower width that the framework searches for. Part of the layout
559/// cache key (so a mode flip is never cached away).
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
561pub enum TextWrap {
562 /// Greedy line breaking — parley's native behavior. The default; the
563 /// only mode that costs nothing.
564 #[default]
565 Normal,
566 /// Even line lengths (CSS `text-wrap: balance`). After the greedy wrap
567 /// yields N lines, the smallest wrap width still yielding N lines is
568 /// found by binary search, so lines come out evenly filled instead of
569 /// `[full, full, short]`. For short, high-impact text — headings,
570 /// titles, pull quotes — not body copy. Line count is preserved.
571 Balance,
572 /// Avoid a stranded last word (CSS `text-wrap: pretty`). If the greedy
573 /// wrap leaves the final line a single short word (an orphan), the wrap
574 /// width is nudged down just enough to pull a second word onto the last
575 /// line, without adding a line. Best-effort: when no such width exists,
576 /// the greedy result is kept unchanged. For paragraphs.
577 Pretty,
578}
579
580/// Figure (numeral) shape. Old-style figures have varying heights and
581/// descenders that sit naturally in serif prose; lining figures are
582/// uniform cap-height digits for data and UI. `Default` leaves the font's
583/// own default figures untouched. Maps to the `onum`/`lnum` OpenType features.
584#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
585pub enum FigureStyle {
586 /// Leave the font's default figures.
587 #[default]
588 Default,
589 /// Lining figures (`lnum`): uniform cap-height digits.
590 Lining,
591 /// Old-style / text figures (`onum`): ascending and descending digits.
592 OldStyle,
593}
594
595/// Figure spacing. Proportional figures are individually spaced for prose;
596/// tabular figures share one advance so columns of numbers align and values
597/// that update in place don't jump. `Default` leaves the font's own spacing.
598/// Maps to the `pnum`/`tnum` OpenType features.
599#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
600pub enum NumericSpacing {
601 /// Leave the font's default spacing.
602 #[default]
603 Default,
604 /// Proportional figures (`pnum`): naturally spaced for prose.
605 Proportional,
606 /// Tabular figures (`tnum`): fixed-width for aligned columns.
607 Tabular,
608}
609
610/// A typed set of OpenType features applied to a text run. Orthogonal axes:
611/// figure shape (`figures`) and figure spacing (`spacing`) compose freely
612/// (e.g. tabular + old-style is valid), small caps, standard ligatures, and
613/// fractions are independent toggles. The default enables nothing, leaving
614/// every glyph at the font's own defaults. Built into a CSS
615/// `font-feature-settings` string for parley; part of the layout cache key.
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
617pub struct FontFeatures {
618 /// Figure shape (`onum`/`lnum`).
619 pub figures: FigureStyle,
620 /// Figure spacing (`pnum`/`tnum`).
621 pub spacing: NumericSpacing,
622 /// Small capitals for lowercase letters (`smcp`).
623 pub small_caps: bool,
624 /// Standard ligatures (`liga`): `None` = font default, `Some(false)`
625 /// disables, `Some(true)` forces on. Most fonts enable `liga` already.
626 pub ligatures: Option<bool>,
627 /// Common fractions (`frac`): turns `1/2` into a single fraction glyph.
628 pub fractions: bool,
629}
630
631impl FontFeatures {
632 /// The CSS `font-feature-settings` string for these features, or `None`
633 /// when nothing is enabled. Tags are emitted in a fixed order so the
634 /// output is deterministic: figures, spacing, small caps, ligatures,
635 /// fractions — e.g. `"onum" 1, "tnum" 1`.
636 pub(crate) fn feature_string(&self) -> Option<String> {
637 let mut parts: Vec<&'static str> = Vec::new();
638 match self.figures {
639 FigureStyle::Default => {}
640 FigureStyle::Lining => parts.push("\"lnum\" 1"),
641 FigureStyle::OldStyle => parts.push("\"onum\" 1"),
642 }
643 match self.spacing {
644 NumericSpacing::Default => {}
645 NumericSpacing::Proportional => parts.push("\"pnum\" 1"),
646 NumericSpacing::Tabular => parts.push("\"tnum\" 1"),
647 }
648 if self.small_caps {
649 parts.push("\"smcp\" 1");
650 }
651 match self.ligatures {
652 None => {}
653 Some(true) => parts.push("\"liga\" 1"),
654 Some(false) => parts.push("\"liga\" 0"),
655 }
656 if self.fractions {
657 parts.push("\"frac\" 1");
658 }
659 if parts.is_empty() {
660 None
661 } else {
662 Some(parts.join(", "))
663 }
664 }
665}
666
667/// How the `opsz` (optical size) variation axis of a *variable* font is set.
668/// Optical-size masters are drawn for a size range: fine, high-contrast cuts
669/// at large display sizes and sturdier, more open cuts at small text sizes, so
670/// a face looks right across the scale instead of one master scaled up and
671/// down. Maps to CSS `font-optical-sizing` / the `opsz` `font-variation-settings`
672/// axis.
673///
674/// This only affects faces that carry an `opsz` axis (e.g. the bundled Fraunces
675/// text serif); static faces — the embedded Inter, JetBrains Mono — have no
676/// such axis, so it is a no-op for them. The default ([`OpticalSizing::Inherit`])
677/// follows the theme's [`Theme::optical_sizing`], which is `Auto` out of the box
678/// (the CSS default `font-optical-sizing: auto`), so a variable text face tracks
679/// its `opsz` axis with no per-element setup; opt out per element with `Default`.
680///
681/// [`Theme::optical_sizing`]: crate::Theme::optical_sizing
682#[derive(Debug, Clone, Copy, PartialEq, Default)]
683pub enum OpticalSizing {
684 /// Inherit the theme default ([`Theme::optical_sizing`](crate::Theme::optical_sizing)).
685 /// The default; an unresolved style (e.g. before theme resolution) treats it
686 /// as [`Auto`](Self::Auto).
687 #[default]
688 Inherit,
689 /// Leave the font's own default `opsz` (emit no variation) — opt this element
690 /// out of optical sizing entirely.
691 Default,
692 /// `opsz` tracks the rendered size in px (CSS `font-optical-sizing: auto`):
693 /// small text picks up the text-optical master, large sizes the display
694 /// master. The everyday choice for a variable text face.
695 Auto,
696 /// Pin `opsz` to a fixed axis value (the font's `opsz` units, ≈ points),
697 /// independent of the rendered size. For showing one optical master at any
698 /// size (specimens, deliberate contrast).
699 Fixed(f32),
700}
701
702impl OpticalSizing {
703 /// The `opsz` axis value to apply for text rendered at `px` logical pixels,
704 /// or `None` to leave the font's default (emit no `opsz` variation).
705 /// [`Auto`](Self::Auto) returns `px`; [`Fixed`](Self::Fixed) its (clamped
706 /// non-negative) value.
707 #[must_use]
708 pub fn opsz_at(self, px: f32) -> Option<f32> {
709 match self {
710 // Unresolved `Inherit` falls back to `Auto` (the stock theme value),
711 // so text shaped without theme resolution still tracks `opsz`.
712 OpticalSizing::Inherit | OpticalSizing::Auto => Some(px.max(0.0)),
713 OpticalSizing::Default => None,
714 OpticalSizing::Fixed(v) => Some(v.max(0.0)),
715 }
716 }
717}
718
719/// The text style group. `color`, `line_height`, and `letter_spacing`
720/// default to the theme/text-size tokens when `None`.
721#[derive(Debug, Clone, Copy, PartialEq, Default)]
722pub struct TextStyle {
723 /// Size on the typographic scale.
724 pub size: TextSize,
725 /// Free-form size in logical px, overriding the scale token (editorial
726 /// display sizes). Line height defaults to 1.25 when set.
727 pub size_px: Option<f32>,
728 /// Font weight.
729 pub weight: Weight,
730 /// Text color; defaults to the theme's `text` role.
731 pub color: Option<Color>,
732 /// Line height multiple; defaults to the size token's value.
733 pub line_height: Option<f32>,
734 /// Letter spacing in em; defaults to the size token's value.
735 pub letter_spacing: Option<f32>,
736 /// Family role (sans or mono).
737 pub family: FamilyRole,
738 /// Horizontal alignment within the text box.
739 pub align: TextAlign,
740 /// Maximum number of lines before truncation with an ellipsis.
741 pub max_lines: Option<u32>,
742 /// OpenType features applied to the run (figures, spacing, small caps,
743 /// ligatures, fractions). Defaults to the font's own defaults.
744 pub features: FontFeatures,
745 /// Line-breaking refinement (balance / pretty). Defaults to greedy
746 /// [`TextWrap::Normal`], which costs nothing; other modes do extra
747 /// line-break passes inside shaping for this element only.
748 pub wrap: TextWrap,
749 /// Optical sizing: how the `opsz` variation axis of a variable font is set.
750 /// Defaults to [`OpticalSizing::Inherit`] — the theme's
751 /// [`Theme::optical_sizing`](crate::Theme::optical_sizing) (`Auto` out of the
752 /// box). A no-op on static faces; opt out per element with `Default`.
753 pub optical: OpticalSizing,
754}
755
756/// A foreground filter applied to an element's *own* rendered content (CSS
757/// `filter:`), as opposed to [`Style::backdrop_blur`] which filters what shows
758/// *through* a translucent element. Resolved by the shell's two-pass renderer:
759/// the element's pixels are read back, filtered on the CPU (deterministically),
760/// and composited in place. Each variant carries one lever in logical px (blur
761/// radius) or as a unit multiplier (`1.0` = identity).
762#[derive(Debug, Clone, Copy, PartialEq)]
763pub enum ElementFilter {
764 /// Gaussian-approximating blur; the value is the blur radius in logical px
765 /// (a standard deviation), realized as a deterministic 3-pass integer box
766 /// blur. `0.0` is a no-op.
767 Blur(f32),
768 /// Brightness multiplier on each color channel (`1.0` unchanged, `0.5`
769 /// half-bright, `>1.0` brighter, clamped at the channel ceiling).
770 Brightness(f32),
771 /// Saturation multiplier about each pixel's luma (`1.0` unchanged, `0.0`
772 /// grayscale, `>1.0` more saturated, clamped into gamut).
773 Saturate(f32),
774}
775
776/// A luminous specular edge rim — the iconic Liquid Glass perimeter light. A
777/// directional highlight that wraps the whole rounded silhouette, brightest on
778/// the edge that faces a fixed light (the top, by default) and fading toward the
779/// far side, so a translucent pane reads as lit, lensed glass rather than a flat
780/// outline. Painted as a gradient-brushed stroke just inside the silhouette,
781/// over an optional faint dark inner contact line that gives the rim a sense of
782/// thickness (the "double edge"). `None` (the default) paints no rim — every
783/// non-glass element stays byte-identical. Set by
784/// [`Surface::Glass`](crate::Surface::Glass); layered over the flat
785/// [`highlight_top`](Style::highlight_top) bar on glass (both are painted).
786#[derive(Debug, Clone, Copy, PartialEq)]
787pub struct SpecularEdge {
788 /// Light direction in CSS gradient degrees (`0` = up, `90` = right): the rim
789 /// is brightest on the edge facing this way and shaded on the opposite edge.
790 /// The Liquid Glass default light is from the top
791 /// ([`GLASS_LIGHT_DEG`](crate::tokens::GLASS_LIGHT_DEG)).
792 pub light_deg: f32,
793 /// Peak white alpha on the lit edge (the top), `0.0..=1.0` — the bright half
794 /// of the lens.
795 pub intensity: f32,
796 /// Peak dark alpha on the unlit far edge (the bottom), `0.0..=1.0` — the
797 /// shaded underside that makes the rim read as a lit bevel (a lens), not a
798 /// flat outline. A single rim stroke ramps from this shade, through clear, to
799 /// `intensity`, so the edge is light on top and dark on the bottom.
800 pub shade: f32,
801}
802
803impl SpecularEdge {
804 /// The Liquid Glass rim: a top-lit edge — bright white along the top and
805 /// upper corners, darkening to a shaded underside at the bottom — so the
806 /// perimeter reads as lit, lensed glass.
807 #[must_use]
808 pub const fn glass() -> Self {
809 Self {
810 light_deg: crate::tokens::GLASS_LIGHT_DEG,
811 intensity: 0.6,
812 shade: 0.18,
813 }
814 }
815}
816
817/// A directional body sheen across an element's face — the raking light that
818/// makes a translucent pane read as lit glass instead of a flat, uniform tint.
819/// A soft gradient wash, white at the end facing the light (top-left) grading
820/// through transparent to an optional faint shade at the far end, source-over
821/// the fill and clipped to the rounded silhouette. `None` (the default) paints
822/// no sheen. Pairs with [`SpecularEdge`]; both are set by
823/// [`Surface::Glass`](crate::Surface::Glass).
824#[derive(Debug, Clone, Copy, PartialEq)]
825pub struct Sheen {
826 /// Gradient axis in CSS degrees (`0` = up, `90` = right): the bright end
827 /// faces the light, the shaded end is opposite.
828 pub light_deg: f32,
829 /// White alpha at the lit (near) end of the wash, `0.0..=1.0`.
830 pub top: f32,
831 /// Dark alpha at the far end (`0.0` = a one-sided, brighten-only sheen).
832 pub bottom: f32,
833}
834
835impl Sheen {
836 /// The Liquid Glass face sheen: a gentle white rake from the upper-left
837 /// fading out across the pane, with a whisper of shade at the lower-right.
838 #[must_use]
839 pub const fn glass() -> Self {
840 Self {
841 light_deg: 135.0,
842 top: 0.12,
843 bottom: 0.06,
844 }
845 }
846}
847
848/// Backdrop-adaptive vibrancy for a glass material. In the headless two-pass
849/// renderer — where the frosted backdrop image is in hand at paint time — the
850/// painter measures the mean luminance of the blurred crop behind the pane and
851/// shifts the tint's OKLCH lightness toward white over dark backdrops and toward
852/// black over light ones, so the frosted surface keeps a stable contrast against
853/// whatever floats behind it. This is Fluent Acrylic's "luminosity clamp" idea
854/// reduced to a single mean-luminance lightness shift (Apple Liquid Glass does
855/// the same tonal adaptation, without a published formula). `None` (the default)
856/// keeps a fixed tint, so non-glass elements — and the single-pass live window,
857/// which has no backdrop image — stay byte-identical. Set by
858/// [`Surface::Glass`](crate::Surface::Glass).
859#[derive(Debug, Clone, Copy, PartialEq)]
860pub struct AdaptiveTint {
861 /// Backdrop luminance (`0` = black .. `1` = white) the shift pivots around: a
862 /// backdrop at exactly this luminance leaves the tint unchanged.
863 pub pivot: f32,
864 /// Lightness-shift gain. The tint's OKLCH lightness moves by
865 /// `gain * (pivot - backdrop_luminance)`, so a fully dark backdrop brightens
866 /// it by up to `gain * pivot` and a fully light one darkens it by up to
867 /// `gain * (1 - pivot)`.
868 pub gain: f32,
869}
870
871impl AdaptiveTint {
872 /// The calibrated glass recipe: pivot near mid-luminance, a calm gain tuned
873 /// so the adaptation reads as the material picking up the room behind it
874 /// without ever turning the pane opaque.
875 #[must_use]
876 pub const fn glass() -> Self {
877 Self {
878 pivot: 0.55,
879 gain: 0.20,
880 }
881 }
882}
883
884/// The complete style of an element: layout, paint, and text groups.
885#[derive(Debug, Clone, PartialEq)]
886pub struct Style {
887 // -- layout --
888 /// Display mode.
889 pub display: Display,
890 /// Flex main axis.
891 pub direction: Direction,
892 /// Whether flex children wrap.
893 pub wrap: bool,
894 /// Cross-axis alignment of children.
895 pub align_items: AlignItems,
896 /// Override of the parent's `align_items` for this element.
897 pub align_self: Option<AlignItems>,
898 /// Main-axis distribution of children.
899 pub justify_content: JustifyContent,
900 /// Multi-line alignment.
901 pub align_content: AlignContent,
902 /// Gap between children (both axes), logical px.
903 pub gap: f32,
904 /// Inner padding.
905 pub padding: Edges,
906 /// Outer margin.
907 pub margin: Edges,
908 /// Offsets for positioned elements.
909 pub inset: Inset,
910 /// Positioning scheme.
911 pub position: Position,
912 /// Preferred width.
913 pub width: Length,
914 /// Preferred height.
915 pub height: Length,
916 /// Minimum width.
917 pub min_width: Length,
918 /// Maximum width.
919 pub max_width: Length,
920 /// Minimum height.
921 pub min_height: Length,
922 /// Maximum height.
923 pub max_height: Length,
924 /// Flex grow factor.
925 pub flex_grow: f32,
926 /// Flex shrink factor.
927 pub flex_shrink: f32,
928 /// Flex basis.
929 pub flex_basis: Length,
930 /// Grid template columns (when `display` is `Grid`).
931 pub grid_template_columns: Vec<GridTemplate>,
932 /// Grid template rows.
933 pub grid_template_rows: Vec<GridTemplate>,
934 /// Column placement when inside a grid.
935 pub grid_column: GridPlace,
936 /// Row placement when inside a grid.
937 pub grid_row: GridPlace,
938 /// `grid-template-areas`: rows of cells, each `Some(name)` or `None` (an
939 /// empty `.` cell). Children placed with [`Style::grid_area`] occupy the
940 /// named rectangle. Empty when unused.
941 pub grid_template_areas: Vec<Vec<Option<String>>>,
942 /// Named-area placement (CSS `grid-area`): occupies the named area on both
943 /// axes, resolved against the parent's `grid_template_areas`.
944 pub grid_area: Option<String>,
945 /// Named-line column placement (CSS `grid-column: a / b`).
946 pub grid_column_lines: GridLines,
947 /// Named-line row placement (CSS `grid-row: a / b`).
948 pub grid_row_lines: GridLines,
949 /// Column line names, positional: the i-th entry names the (i+1)-th column
950 /// line. Empty when unused.
951 pub grid_col_line_names: Vec<Vec<String>>,
952 /// Row line names, positional: the i-th entry names the (i+1)-th row line.
953 pub grid_row_line_names: Vec<Vec<String>>,
954 /// Horizontal overflow.
955 pub overflow_x: Overflow,
956 /// Vertical overflow.
957 pub overflow_y: Overflow,
958 /// Sticky offset from the scroll viewport's top edge (with `Position::Sticky`).
959 pub sticky_top: Option<f32>,
960 /// Sticky offset from the scroll viewport's right edge.
961 pub sticky_right: Option<f32>,
962 /// Sticky offset from the scroll viewport's bottom edge.
963 pub sticky_bottom: Option<f32>,
964 /// Sticky offset from the scroll viewport's left edge.
965 pub sticky_left: Option<f32>,
966
967 // -- paint --
968 /// Background paint.
969 pub fill: Option<Paint>,
970 /// Uniform border.
971 pub border: Option<Border>,
972 /// Per-edge border strokes, independent of the uniform [`border`](Self::border).
973 pub side_borders: EdgeBorders,
974 /// Per-corner radii.
975 pub corner_radius: CornerRadius,
976 /// Continuous-curvature corner smoothing (Figma's "corner smoothing").
977 /// `None` (the default) inherits [`Theme::corner_smoothing`]; `Some(s)` with
978 /// `s` in `0.0..=1.0` overrides it for this element, and `Some(0.0)` forces
979 /// exact circular arcs. As smoothing rises toward `1.0` the corners blend
980 /// toward a fuller superellipse (an Apple-style squircle): the curve hugs
981 /// each straight edge longer and turns more gradually, removing the
982 /// curvature discontinuity ("kink") where a straight edge meets a circular
983 /// arc. Fill, border, and clip share one path so they stay aligned.
984 /// Structural, not animated: it is never lerped, so a target state's
985 /// smoothing simply wins.
986 ///
987 /// [`Theme::corner_smoothing`]: crate::Theme::corner_smoothing
988 pub corner_smoothing: Option<f32>,
989 /// A shadow elevation token, expanded against the theme at resolution.
990 pub shadow_token: Option<crate::tokens::ShadowToken>,
991 /// Concrete drop shadow layers, painted bottom-up. Filled from
992 /// `shadow_token` during style resolution; may also be set directly.
993 pub shadows: Vec<Shadow>,
994 /// A 1px inset highlight along the top inner edge (CSS `inset 0 1px 0`):
995 /// the cheapest "raised, crafted" signal on a solid control. Painted over
996 /// the fill, clipped to the corner radius. Usually white at low alpha.
997 pub highlight_top: Option<Color>,
998 /// A luminous specular edge rim wrapping the rounded silhouette (the iconic
999 /// Liquid Glass perimeter light). `None` (the default) paints no rim, so
1000 /// non-glass elements stay byte-identical. See [`SpecularEdge`].
1001 pub specular_edge: Option<SpecularEdge>,
1002 /// A directional body sheen across the face — the raking light that makes a
1003 /// translucent pane read as lit glass. `None` (the default) paints no sheen.
1004 /// See [`Sheen`].
1005 pub sheen: Option<Sheen>,
1006 /// Backdrop-adaptive vibrancy: shifts the glass tint's lightness by the mean
1007 /// luminance of the frosted backdrop behind it (headless-only — it needs the
1008 /// composited backdrop image). `None` (the default) keeps a fixed tint, so
1009 /// non-glass elements stay byte-identical. See [`AdaptiveTint`].
1010 pub adaptive_tint: Option<AdaptiveTint>,
1011 /// Opacity 0.0..=1.0 applied to the whole subtree.
1012 pub opacity: f32,
1013 /// Uniform scale applied at paint time about the element's center
1014 /// (1.0 = no transform). Pressed controls dip to [`crate::tokens::PRESS_SCALE`];
1015 /// it never affects layout or hit-testing, and it animates. Spring
1016 /// transitions may carry it past the target for a tactile overshoot.
1017 pub scale: f32,
1018 /// Paint-time translation in logical px `(x, y)` — never affects layout or
1019 /// hit-testing; animatable.
1020 pub translate: (f32, f32),
1021 /// Paint-time rotation in degrees about the element center; animatable.
1022 pub rotate: f32,
1023 /// Paint-time skew in degrees `(x, y)` about the element center; animatable.
1024 pub skew: (f32, f32),
1025 /// Non-uniform paint-time scale `(x, y)` composed with the uniform
1026 /// [`scale`](Self::scale) (`(1.0, 1.0)` = no transform). Like every paint
1027 /// transform it never affects layout, and hit-testing follows the paint;
1028 /// animatable.
1029 pub scale_xy: (f32, f32),
1030 /// The pivot for paint-time transforms, as a fraction of the element's
1031 /// rect (`(0.0, 0.0)` = top-left, `(0.5, 0.5)` = center, the default —
1032 /// CSS `transform-origin` semantics). Values outside `0..=1` pivot
1033 /// about a point beyond the rect; animatable.
1034 pub transform_origin: (f32, f32),
1035 /// Clip children to the (rounded) bounds.
1036 pub clip: bool,
1037 /// Draw progress of path elements, 0.0..=1.0 (animatable; this is how
1038 /// check marks draw on).
1039 pub path_trim: f32,
1040 /// Backdrop blur radius in logical px: when set (`> 0`), the content
1041 /// *behind* this (translucent) element is read back and blurred before the
1042 /// element's fill composites over it — a real frosted-glass pane. `None`
1043 /// (the default) paints with no backdrop pass, byte-identically to a plain
1044 /// fill. Carried by [`Surface::Glass`](crate::Surface::Glass); realized by
1045 /// the shell's two-pass renderer (deterministic CPU box blur). Inert in the
1046 /// single-pass live-window path (renders as the translucent tint alone).
1047 pub backdrop_blur: Option<f32>,
1048 /// A foreground filter on this element's *own* content (blur / brightness /
1049 /// saturate). `None` (the default) paints the content unfiltered. Realized
1050 /// by the shell's two-pass renderer alongside [`backdrop_blur`](Self::backdrop_blur).
1051 pub element_filter: Option<ElementFilter>,
1052
1053 // -- text --
1054 /// Text properties (used by text elements; inherited defaults elsewhere).
1055 pub text: TextStyle,
1056}
1057
1058impl Default for Style {
1059 fn default() -> Self {
1060 Self {
1061 display: Display::default(),
1062 direction: Direction::default(),
1063 wrap: false,
1064 align_items: AlignItems::default(),
1065 align_self: None,
1066 justify_content: JustifyContent::default(),
1067 align_content: AlignContent::default(),
1068 gap: 0.0,
1069 padding: Edges::default(),
1070 margin: Edges::default(),
1071 inset: Inset::default(),
1072 position: Position::default(),
1073 width: Length::Auto,
1074 height: Length::Auto,
1075 min_width: Length::Auto,
1076 max_width: Length::Auto,
1077 min_height: Length::Auto,
1078 max_height: Length::Auto,
1079 flex_grow: 0.0,
1080 flex_shrink: 1.0,
1081 flex_basis: Length::Auto,
1082 grid_template_columns: Vec::new(),
1083 grid_template_rows: Vec::new(),
1084 grid_column: GridPlace::default(),
1085 grid_row: GridPlace::default(),
1086 grid_template_areas: Vec::new(),
1087 grid_area: None,
1088 grid_column_lines: GridLines::default(),
1089 grid_row_lines: GridLines::default(),
1090 grid_col_line_names: Vec::new(),
1091 grid_row_line_names: Vec::new(),
1092 overflow_x: Overflow::Visible,
1093 overflow_y: Overflow::Visible,
1094 sticky_top: None,
1095 sticky_right: None,
1096 sticky_bottom: None,
1097 sticky_left: None,
1098 fill: None,
1099 border: None,
1100 side_borders: EdgeBorders::default(),
1101 corner_radius: CornerRadius::default(),
1102 corner_smoothing: None,
1103 shadow_token: None,
1104 shadows: Vec::new(),
1105 highlight_top: None,
1106 specular_edge: None,
1107 sheen: None,
1108 adaptive_tint: None,
1109 opacity: 1.0,
1110 scale: 1.0,
1111 translate: (0.0, 0.0),
1112 rotate: 0.0,
1113 skew: (0.0, 0.0),
1114 scale_xy: (1.0, 1.0),
1115 transform_origin: (0.5, 0.5),
1116 clip: false,
1117 path_trim: 1.0,
1118 backdrop_blur: None,
1119 element_filter: None,
1120 text: TextStyle::default(),
1121 }
1122 }
1123}
1124
1125impl Style {
1126 /// The pivot a paint-time transform rotates/scales about: the rect's
1127 /// [`transform_origin`](Self::transform_origin) point (center by
1128 /// default), projected into absolute coordinates. Shared by every
1129 /// transform this style drives — the static paint matrix
1130 /// ([`paint_affine`](Self::paint_affine)) and an exit animation's own
1131 /// scale/translate — so a leaving element's animated half pivots about
1132 /// the same point its frozen static half does.
1133 #[must_use]
1134 pub fn transform_origin_point(&self, rect: kurbo::Rect) -> kurbo::Point {
1135 kurbo::Point::new(
1136 rect.x0 + f64::from(self.transform_origin.0) * rect.width(),
1137 rect.y0 + f64::from(self.transform_origin.1) * rect.height(),
1138 )
1139 }
1140
1141 /// The paint-time affine this style applies over its (untransformed)
1142 /// layout rect — translate / rotate / skew / scale (uniform, then
1143 /// non-uniform) composed about the rect's
1144 /// [`transform_origin`](Self::transform_origin) point — or `None` when
1145 /// the transform is identity. The single source of truth for the paint
1146 /// matrix: painting draws under it, hit-testing inverts it, exit ghosts
1147 /// replay it frozen, and offline samplers (`fenestra-motion`) project
1148 /// bounding boxes through it.
1149 pub fn paint_affine(&self, rect: kurbo::Rect) -> Option<kurbo::Affine> {
1150 let s = self;
1151 let has_transform = (s.scale - 1.0).abs() > 1e-4
1152 || (s.scale_xy.0 - 1.0).abs() > 1e-4
1153 || (s.scale_xy.1 - 1.0).abs() > 1e-4
1154 || s.translate.0.abs() > 1e-4
1155 || s.translate.1.abs() > 1e-4
1156 || s.rotate.abs() > 1e-4
1157 || s.skew.0.abs() > 1e-4
1158 || s.skew.1.abs() > 1e-4;
1159 if !has_transform {
1160 return None;
1161 }
1162 let p = s.transform_origin_point(rect);
1163 // T(translate) · T(p) · R · Skew · S · T(-p)
1164 let mut a = kurbo::Affine::translate((f64::from(s.translate.0), f64::from(s.translate.1)))
1165 * kurbo::Affine::translate((p.x, p.y));
1166 if s.rotate.abs() > 1e-4 {
1167 a *= kurbo::Affine::rotate(f64::from(s.rotate).to_radians());
1168 }
1169 if s.skew.0.abs() > 1e-4 || s.skew.1.abs() > 1e-4 {
1170 a *= kurbo::Affine::new([
1171 1.0,
1172 f64::from(s.skew.1).to_radians().tan(),
1173 f64::from(s.skew.0).to_radians().tan(),
1174 1.0,
1175 0.0,
1176 0.0,
1177 ]);
1178 }
1179 if (s.scale - 1.0).abs() > 1e-4 {
1180 a *= kurbo::Affine::scale(f64::from(s.scale));
1181 }
1182 if (s.scale_xy.0 - 1.0).abs() > 1e-4 || (s.scale_xy.1 - 1.0).abs() > 1e-4 {
1183 a *= kurbo::Affine::scale_non_uniform(f64::from(s.scale_xy.0), f64::from(s.scale_xy.1));
1184 }
1185 a *= kurbo::Affine::translate((-p.x, -p.y));
1186 Some(a)
1187 }
1188}
1189
1190/// A theme-aware partial style overlay: interaction variants and kit
1191/// widgets' deferred base styling both use this shape.
1192pub type ThemedFn = Box<dyn Fn(&crate::theme::Theme, Style) -> Style>;
1193
1194/// Spring parameters for physical motion (see [`Transition::spring`]),
1195/// shared with `fenestra-motion`'s offline sampler and any other frame/tick
1196/// -based consumer — see [`fenestra_anim::SpringSpec`] for the closed-form
1197/// step response.
1198pub use fenestra_anim::SpringSpec;
1199
1200/// Declares which properties animate between style states, and how.
1201#[derive(Debug, Clone, Copy, PartialEq)]
1202pub struct Transition {
1203 /// Animate colors (fill, border, text), lerped in OKLCH.
1204 pub colors: bool,
1205 /// Animate opacity.
1206 pub opacity: bool,
1207 /// Animate lengths (sizes, padding, radii).
1208 pub lengths: bool,
1209 /// Animate position offsets.
1210 pub offsets: bool,
1211 /// Animate shadow alpha.
1212 pub shadows: bool,
1213 /// Duration in milliseconds.
1214 pub duration_ms: f32,
1215 /// Easing curve.
1216 pub easing: crate::tokens::CubicBezier,
1217 /// Physical spring response instead of the duration+curve pair.
1218 /// Lengths and offsets may overshoot; colors, opacity, and shadows
1219 /// clamp at the target (extrapolated colors aren't colors).
1220 pub spring: Option<SpringSpec>,
1221}
1222
1223/// A looping keyframe timeline: style stops at fractional times across one
1224/// period, sampled from the frame clock every frame. Built for ambient
1225/// motion (pulses, shimmers, breathing); one-shot state changes belong to
1226/// [`Transition`]. With reduced motion the first stop is pinned, keeping
1227/// headless renders deterministic.
1228pub struct Keyframes {
1229 pub(crate) stops: Vec<(f32, ThemedFn)>,
1230 pub(crate) duration_ms: f32,
1231 pub(crate) easing: crate::tokens::CubicBezier,
1232}
1233
1234impl Keyframes {
1235 /// A timeline lasting `duration_ms` per cycle (looped).
1236 pub fn new(duration_ms: f32) -> Self {
1237 Self {
1238 stops: Vec::new(),
1239 duration_ms,
1240 easing: crate::tokens::EASE_STANDARD,
1241 }
1242 }
1243
1244 /// Adds a stop at fraction `at` (clamped to 0..=1) transforming the
1245 /// element's resolved base style. For a seamless loop, make the styles
1246 /// at 0 and 1 match.
1247 pub fn stop(self, at: f32, f: impl Fn(Style) -> Style + 'static) -> Self {
1248 self.themed_stop(at, move |_, s| f(s))
1249 }
1250
1251 /// A theme-aware stop, for keyframes that color through tokens.
1252 pub fn themed_stop(
1253 mut self,
1254 at: f32,
1255 f: impl Fn(&crate::theme::Theme, Style) -> Style + 'static,
1256 ) -> Self {
1257 self.stops.push((at.clamp(0.0, 1.0), Box::new(f)));
1258 self.stops.sort_by(|a, b| a.0.total_cmp(&b.0));
1259 self
1260 }
1261
1262 /// Per-segment easing (standard ease by default).
1263 pub fn ease(mut self, easing: crate::tokens::CubicBezier) -> Self {
1264 self.easing = easing;
1265 self
1266 }
1267}
1268
1269impl Transition {
1270 /// The standard hover transition: colors and shadow alpha over the Fast
1271 /// duration with standard easing.
1272 pub fn colors() -> Self {
1273 Self {
1274 colors: true,
1275 opacity: false,
1276 lengths: false,
1277 offsets: false,
1278 shadows: true,
1279 duration_ms: crate::tokens::MotionDuration::Fast.ms(),
1280 easing: crate::tokens::EASE_STANDARD,
1281 spring: None,
1282 }
1283 }
1284
1285 /// Animate every animatable property over the Base duration.
1286 pub fn all() -> Self {
1287 Self {
1288 colors: true,
1289 opacity: true,
1290 lengths: true,
1291 offsets: true,
1292 shadows: true,
1293 duration_ms: crate::tokens::MotionDuration::Base.ms(),
1294 easing: crate::tokens::EASE_STANDARD,
1295 spring: None,
1296 }
1297 }
1298
1299 /// Every property on a brisk spring with a touch of overshoot
1300 /// (stiffness 380, damping 26). Lengths and offsets carry the
1301 /// bounce; colors clamp at the target.
1302 pub fn spring() -> Self {
1303 Self {
1304 spring: Some(SpringSpec {
1305 stiffness: 380.0,
1306 damping: 26.0,
1307 }),
1308 ..Self::all()
1309 }
1310 }
1311
1312 /// Overrides the spring parameters (and switches to spring motion).
1313 pub fn with_spring(mut self, stiffness: f32, damping: f32) -> Self {
1314 self.spring = Some(SpringSpec { stiffness, damping });
1315 self
1316 }
1317
1318 /// Overrides the duration with a token.
1319 pub fn duration(mut self, d: crate::tokens::MotionDuration) -> Self {
1320 self.duration_ms = d.ms();
1321 self
1322 }
1323
1324 /// Overrides the duration in milliseconds.
1325 pub fn duration_ms(mut self, ms: f32) -> Self {
1326 self.duration_ms = ms;
1327 self
1328 }
1329
1330 /// Enables or disables length animation (sizes, padding, radii, trim).
1331 pub fn lengths(mut self, on: bool) -> Self {
1332 self.lengths = on;
1333 self
1334 }
1335
1336 /// Enables or disables offset animation (inset).
1337 pub fn offsets(mut self, on: bool) -> Self {
1338 self.offsets = on;
1339 self
1340 }
1341
1342 /// Enables or disables opacity animation.
1343 pub fn opacity(mut self, on: bool) -> Self {
1344 self.opacity = on;
1345 self
1346 }
1347
1348 /// Overrides the easing curve.
1349 pub fn easing(mut self, e: crate::tokens::CubicBezier) -> Self {
1350 self.easing = e;
1351 self
1352 }
1353}
1354
1355impl From<f32> for Length {
1356 /// Raw `f32` means logical pixels.
1357 fn from(v: f32) -> Self {
1358 Self::Px(v)
1359 }
1360}
1361
1362impl Style {
1363 // -- layout: padding --
1364
1365 /// Padding on all edges.
1366 pub fn p(mut self, v: f32) -> Self {
1367 self.padding = Edges::all(v);
1368 self
1369 }
1370
1371 /// Horizontal padding (left and right).
1372 pub fn px(mut self, v: f32) -> Self {
1373 self.padding.left = v;
1374 self.padding.right = v;
1375 self
1376 }
1377
1378 /// Vertical padding (top and bottom).
1379 pub fn py(mut self, v: f32) -> Self {
1380 self.padding.top = v;
1381 self.padding.bottom = v;
1382 self
1383 }
1384
1385 /// Top padding.
1386 pub fn pt(mut self, v: f32) -> Self {
1387 self.padding.top = v;
1388 self
1389 }
1390
1391 /// Right padding.
1392 pub fn pr(mut self, v: f32) -> Self {
1393 self.padding.right = v;
1394 self
1395 }
1396
1397 /// Bottom padding.
1398 pub fn pb(mut self, v: f32) -> Self {
1399 self.padding.bottom = v;
1400 self
1401 }
1402
1403 /// Left padding.
1404 pub fn pl(mut self, v: f32) -> Self {
1405 self.padding.left = v;
1406 self
1407 }
1408
1409 // -- layout: margin --
1410
1411 /// Margin on all edges.
1412 pub fn m(mut self, v: f32) -> Self {
1413 self.margin = Edges::all(v);
1414 self
1415 }
1416
1417 /// Horizontal margin.
1418 pub fn mx(mut self, v: f32) -> Self {
1419 self.margin.left = v;
1420 self.margin.right = v;
1421 self
1422 }
1423
1424 /// Vertical margin.
1425 pub fn my(mut self, v: f32) -> Self {
1426 self.margin.top = v;
1427 self.margin.bottom = v;
1428 self
1429 }
1430
1431 /// Top margin.
1432 pub fn mt(mut self, v: f32) -> Self {
1433 self.margin.top = v;
1434 self
1435 }
1436
1437 /// Right margin.
1438 pub fn mr(mut self, v: f32) -> Self {
1439 self.margin.right = v;
1440 self
1441 }
1442
1443 /// Bottom margin.
1444 pub fn mb(mut self, v: f32) -> Self {
1445 self.margin.bottom = v;
1446 self
1447 }
1448
1449 /// Left margin.
1450 pub fn ml(mut self, v: f32) -> Self {
1451 self.margin.left = v;
1452 self
1453 }
1454
1455 // -- layout: size and flex --
1456
1457 /// Gap between children, both axes.
1458 pub fn gap(mut self, v: f32) -> Self {
1459 self.gap = v;
1460 self
1461 }
1462
1463 /// Preferred width. Raw `f32` means logical px; use `Length::Pct`/`Auto`.
1464 pub fn w(mut self, v: impl Into<Length>) -> Self {
1465 self.width = v.into();
1466 self
1467 }
1468
1469 /// Preferred height.
1470 pub fn h(mut self, v: impl Into<Length>) -> Self {
1471 self.height = v.into();
1472 self
1473 }
1474
1475 /// Minimum width.
1476 pub fn min_w(mut self, v: impl Into<Length>) -> Self {
1477 self.min_width = v.into();
1478 self
1479 }
1480
1481 /// Maximum width.
1482 pub fn max_w(mut self, v: impl Into<Length>) -> Self {
1483 self.max_width = v.into();
1484 self
1485 }
1486
1487 /// Minimum height.
1488 pub fn min_h(mut self, v: impl Into<Length>) -> Self {
1489 self.min_height = v.into();
1490 self
1491 }
1492
1493 /// Maximum height.
1494 pub fn max_h(mut self, v: impl Into<Length>) -> Self {
1495 self.max_height = v.into();
1496 self
1497 }
1498
1499 /// Width 100%.
1500 pub fn w_full(mut self) -> Self {
1501 self.width = Length::Pct(100.0);
1502 self
1503 }
1504
1505 /// Height 100%.
1506 pub fn h_full(mut self) -> Self {
1507 self.height = Length::Pct(100.0);
1508 self
1509 }
1510
1511 /// Caps this element's width at a reading measure of `chars` `ch` units
1512 /// (a `ch`-based `max-width`). 1ch is the advance of `'0'` in this
1513 /// element's resolved text style, so the column is `chars` `'0'`-widths
1514 /// wide regardless of how wide the window is — a proportional face fits
1515 /// somewhat *more* than `chars` real glyphs per line (the default
1516 /// [`MEASURE_CH`](crate::MEASURE_CH) is tuned for ~66 characters). Set the
1517 /// element's text `size` and `family` to the prose it wraps so the measure
1518 /// tracks the real text.
1519 pub fn measure(mut self, chars: f32) -> Self {
1520 self.max_width = Length::Ch(chars);
1521 self
1522 }
1523
1524 /// Preferred width in `ch` units (see [`Length::Ch`]).
1525 pub fn w_ch(mut self, chars: f32) -> Self {
1526 self.width = Length::Ch(chars);
1527 self
1528 }
1529
1530 /// Minimum width in `ch` units.
1531 pub fn min_w_ch(mut self, chars: f32) -> Self {
1532 self.min_width = Length::Ch(chars);
1533 self
1534 }
1535
1536 /// Maximum width in `ch` units (alias of [`Style::measure`] for symmetry).
1537 pub fn max_w_ch(mut self, chars: f32) -> Self {
1538 self.max_width = Length::Ch(chars);
1539 self
1540 }
1541
1542 /// Flex grow 1.
1543 pub fn grow(mut self) -> Self {
1544 self.flex_grow = 1.0;
1545 self
1546 }
1547
1548 /// Flex shrink 0.
1549 pub fn shrink0(mut self) -> Self {
1550 self.flex_shrink = 0.0;
1551 self
1552 }
1553
1554 // -- layout: alignment --
1555
1556 /// Align children to the cross-axis start.
1557 pub fn items_start(mut self) -> Self {
1558 self.align_items = AlignItems::Start;
1559 self
1560 }
1561
1562 /// Center children on the cross axis.
1563 pub fn items_center(mut self) -> Self {
1564 self.align_items = AlignItems::Center;
1565 self
1566 }
1567
1568 /// Align children to the cross-axis end.
1569 pub fn items_end(mut self) -> Self {
1570 self.align_items = AlignItems::End;
1571 self
1572 }
1573
1574 /// Align children on their first text baseline (rows only).
1575 pub fn items_baseline(mut self) -> Self {
1576 self.align_items = AlignItems::Baseline;
1577 self
1578 }
1579
1580 /// Override the parent's cross-axis alignment for this element alone,
1581 /// packing it toward the cross-axis start (so it hugs its content instead
1582 /// of stretching).
1583 pub fn self_start(mut self) -> Self {
1584 self.align_self = Some(AlignItems::Start);
1585 self
1586 }
1587
1588 /// Override the parent's cross-axis alignment for this element alone,
1589 /// centering it on the cross axis.
1590 pub fn self_center(mut self) -> Self {
1591 self.align_self = Some(AlignItems::Center);
1592 self
1593 }
1594
1595 /// Override the parent's cross-axis alignment for this element alone,
1596 /// packing it toward the cross-axis end.
1597 pub fn self_end(mut self) -> Self {
1598 self.align_self = Some(AlignItems::End);
1599 self
1600 }
1601
1602 /// Override the parent's cross-axis alignment for this element alone,
1603 /// stretching it to fill the cross axis.
1604 pub fn self_stretch(mut self) -> Self {
1605 self.align_self = Some(AlignItems::Stretch);
1606 self
1607 }
1608
1609 /// Pack children toward the main-axis start.
1610 pub fn justify_start(mut self) -> Self {
1611 self.justify_content = JustifyContent::Start;
1612 self
1613 }
1614
1615 /// Center children on the main axis.
1616 pub fn justify_center(mut self) -> Self {
1617 self.justify_content = JustifyContent::Center;
1618 self
1619 }
1620
1621 /// Pack children toward the main-axis end.
1622 pub fn justify_end(mut self) -> Self {
1623 self.justify_content = JustifyContent::End;
1624 self
1625 }
1626
1627 /// Distribute children with space between.
1628 pub fn justify_between(mut self) -> Self {
1629 self.justify_content = JustifyContent::SpaceBetween;
1630 self
1631 }
1632
1633 /// Allow flex children to wrap.
1634 pub fn wrap(mut self) -> Self {
1635 self.wrap = true;
1636 self
1637 }
1638
1639 // -- layout: position and overflow --
1640
1641 /// Position absolutely against the nearest relative ancestor.
1642 pub fn absolute(mut self) -> Self {
1643 self.position = Position::Absolute;
1644 self
1645 }
1646
1647 /// Offset from the top (positioned elements).
1648 pub fn top(mut self, v: f32) -> Self {
1649 self.inset.top = Some(v);
1650 self
1651 }
1652
1653 /// Offset from the right.
1654 pub fn right(mut self, v: f32) -> Self {
1655 self.inset.right = Some(v);
1656 self
1657 }
1658
1659 /// Offset from the bottom.
1660 pub fn bottom(mut self, v: f32) -> Self {
1661 self.inset.bottom = Some(v);
1662 self
1663 }
1664
1665 /// Offset from the left.
1666 pub fn left(mut self, v: f32) -> Self {
1667 self.inset.left = Some(v);
1668 self
1669 }
1670
1671 /// Clip children to the (rounded) bounds.
1672 pub fn overflow_hidden(mut self) -> Self {
1673 self.overflow_x = Overflow::Hidden;
1674 self.overflow_y = Overflow::Hidden;
1675 self.clip = true;
1676 self
1677 }
1678
1679 /// Vertical scrolling with clipped content (scroll state lands in M3).
1680 pub fn scroll_y(mut self) -> Self {
1681 self.overflow_y = Overflow::Scroll;
1682 self.clip = true;
1683 self
1684 }
1685
1686 /// Horizontal scrolling with clipped content.
1687 pub fn scroll_x(mut self) -> Self {
1688 self.overflow_x = Overflow::Scroll;
1689 self.clip = true;
1690 self
1691 }
1692
1693 /// Scrolling on both axes with clipped content.
1694 pub fn scroll_xy(mut self) -> Self {
1695 self.overflow_x = Overflow::Scroll;
1696 self.overflow_y = Overflow::Scroll;
1697 self.clip = true;
1698 self
1699 }
1700
1701 /// Sticks the element `offset` px below the scroll viewport's top edge once
1702 /// it would scroll past (CSS `position: sticky; top: offset`).
1703 pub fn sticky_top(mut self, offset: f32) -> Self {
1704 self.position = Position::Sticky;
1705 self.sticky_top = Some(offset);
1706 self
1707 }
1708
1709 /// Sticks the element `offset` px above the scroll viewport's bottom edge.
1710 pub fn sticky_bottom(mut self, offset: f32) -> Self {
1711 self.position = Position::Sticky;
1712 self.sticky_bottom = Some(offset);
1713 self
1714 }
1715
1716 /// Sticks the element `offset` px right of the scroll viewport's left edge.
1717 pub fn sticky_left(mut self, offset: f32) -> Self {
1718 self.position = Position::Sticky;
1719 self.sticky_left = Some(offset);
1720 self
1721 }
1722
1723 /// Sticks the element `offset` px left of the scroll viewport's right edge.
1724 pub fn sticky_right(mut self, offset: f32) -> Self {
1725 self.position = Position::Sticky;
1726 self.sticky_right = Some(offset);
1727 self
1728 }
1729
1730 // -- paint --
1731
1732 /// Background fill: a solid color or gradient.
1733 pub fn bg(mut self, paint: impl Into<Paint>) -> Self {
1734 self.fill = Some(paint.into());
1735 self
1736 }
1737
1738 /// Uniform border (a stroke on the element's edge).
1739 pub fn border(mut self, width: f32, color: Color) -> Self {
1740 self.border = Some(Border { width, color });
1741 self
1742 }
1743
1744 /// A border stroke on just the top edge (straight hairline, square corners).
1745 pub fn border_top(mut self, width: f32, color: Color) -> Self {
1746 self.side_borders.top = Some(Border { width, color });
1747 self
1748 }
1749
1750 /// A border stroke on just the right edge.
1751 pub fn border_right(mut self, width: f32, color: Color) -> Self {
1752 self.side_borders.right = Some(Border { width, color });
1753 self
1754 }
1755
1756 /// A border stroke on just the bottom edge.
1757 pub fn border_bottom(mut self, width: f32, color: Color) -> Self {
1758 self.side_borders.bottom = Some(Border { width, color });
1759 self
1760 }
1761
1762 /// A border stroke on just the left edge.
1763 pub fn border_left(mut self, width: f32, color: Color) -> Self {
1764 self.side_borders.left = Some(Border { width, color });
1765 self
1766 }
1767
1768 /// A crisp `width`-px ring *just outside* the box, hugging the corner
1769 /// radius — the "ring, not border" look (Geist). Rendered as a zero-blur
1770 /// spread shadow, so unlike [`border`](Self::border) (an edge stroke) it
1771 /// sits outside the element, never covers its content or children, and
1772 /// recolors with zero layout cost — ideal for selection/emphasis rings and
1773 /// sub-pixel hairlines. Composes with shadow tokens (the ring paints on top
1774 /// of any drop shadow). Stack multiple rings by calling it more than once.
1775 pub fn ring(mut self, width: f32, color: Color) -> Self {
1776 self.shadows.push(Shadow {
1777 dx: 0.0,
1778 dy: 0.0,
1779 blur: 0.0,
1780 spread: width,
1781 color,
1782 });
1783 self
1784 }
1785
1786 /// The same corner radius on all corners.
1787 pub fn rounded(mut self, r: f32) -> Self {
1788 self.corner_radius = CornerRadius::all(r);
1789 self
1790 }
1791
1792 /// Fully-rounded corners (pill / circle).
1793 pub fn rounded_full(mut self) -> Self {
1794 self.corner_radius = CornerRadius::all(crate::tokens::R_FULL);
1795 self
1796 }
1797
1798 /// Rounds the top two corners, leaving the others unchanged.
1799 pub fn rounded_t(mut self, r: f32) -> Self {
1800 self.corner_radius.tl = r;
1801 self.corner_radius.tr = r;
1802 self
1803 }
1804
1805 /// Rounds the bottom two corners, leaving the others unchanged.
1806 pub fn rounded_b(mut self, r: f32) -> Self {
1807 self.corner_radius.br = r;
1808 self.corner_radius.bl = r;
1809 self
1810 }
1811
1812 /// Rounds the left two corners, leaving the others unchanged.
1813 pub fn rounded_l(mut self, r: f32) -> Self {
1814 self.corner_radius.tl = r;
1815 self.corner_radius.bl = r;
1816 self
1817 }
1818
1819 /// Rounds the right two corners, leaving the others unchanged.
1820 pub fn rounded_r(mut self, r: f32) -> Self {
1821 self.corner_radius.tr = r;
1822 self.corner_radius.br = r;
1823 self
1824 }
1825
1826 /// Sets each corner radius independently: top-left, top-right,
1827 /// bottom-right, bottom-left (clockwise from the top-left).
1828 pub fn corners(mut self, tl: f32, tr: f32, br: f32, bl: f32) -> Self {
1829 self.corner_radius = CornerRadius { tl, tr, br, bl };
1830 self
1831 }
1832
1833 /// Overrides continuous-curvature corner smoothing for this element (see
1834 /// [`Style::corner_smoothing`]), opting out of the theme default. `0.0`
1835 /// keeps exact circular arcs; higher values blend toward a fuller squircle.
1836 /// Clamped to `0.0..=1.0`.
1837 pub fn corner_smoothing(mut self, s: f32) -> Self {
1838 self.corner_smoothing = Some(s.clamp(0.0, 1.0));
1839 self
1840 }
1841
1842 /// A shadow elevation token, resolved against the theme at render time.
1843 pub fn shadow(mut self, token: crate::tokens::ShadowToken) -> Self {
1844 self.shadow_token = Some(token);
1845 self
1846 }
1847
1848 /// A 1px inset highlight along the top inner edge — the subtle top sheen
1849 /// that makes a solid control read as raised. Usually a low-alpha white.
1850 pub fn highlight_top(mut self, color: Color) -> Self {
1851 self.highlight_top = Some(color);
1852 self
1853 }
1854
1855 /// A luminous specular edge rim wrapping the rounded silhouette — the iconic
1856 /// Liquid Glass perimeter light. See [`SpecularEdge`] (and
1857 /// [`SpecularEdge::glass`] for the stock recipe).
1858 pub fn specular_edge(mut self, edge: SpecularEdge) -> Self {
1859 self.specular_edge = Some(edge);
1860 self
1861 }
1862
1863 /// A directional body sheen across the face — the raking light that makes a
1864 /// translucent pane read as lit glass. See [`Sheen`] (and [`Sheen::glass`]).
1865 pub fn sheen(mut self, sheen: Sheen) -> Self {
1866 self.sheen = Some(sheen);
1867 self
1868 }
1869
1870 /// Backdrop-adaptive vibrancy — shift the glass tint's lightness by the mean
1871 /// luminance of the frosted backdrop behind it (headless-only). See
1872 /// [`AdaptiveTint`] (and [`AdaptiveTint::glass`] for the stock recipe).
1873 pub fn adaptive_tint(mut self, adaptive: AdaptiveTint) -> Self {
1874 self.adaptive_tint = Some(adaptive);
1875 self
1876 }
1877
1878 /// Subtree opacity 0.0..=1.0.
1879 pub fn opacity(mut self, v: f32) -> Self {
1880 self.opacity = v;
1881 self
1882 }
1883
1884 /// Paint-time uniform scale about the element center (1.0 = none). Used
1885 /// for press feedback; never disturbs layout.
1886 pub fn scale(mut self, v: f32) -> Self {
1887 self.scale = v;
1888 self
1889 }
1890
1891 /// Paint-time translation in logical px (never affects layout). Animatable.
1892 pub fn translate(mut self, x: f32, y: f32) -> Self {
1893 self.translate = (x, y);
1894 self
1895 }
1896
1897 /// Paint-time rotation in degrees about the element center. Animatable.
1898 pub fn rotate(mut self, degrees: f32) -> Self {
1899 self.rotate = degrees;
1900 self
1901 }
1902
1903 /// Paint-time skew in degrees `(x, y)` about the element center. Animatable.
1904 pub fn skew(mut self, x_degrees: f32, y_degrees: f32) -> Self {
1905 self.skew = (x_degrees, y_degrees);
1906 self
1907 }
1908
1909 /// Non-uniform paint-time scale `(x, y)`, composed with the uniform
1910 /// [`scale`](Self::scale). Never disturbs layout; animatable.
1911 pub fn scale_xy(mut self, x: f32, y: f32) -> Self {
1912 self.scale_xy = (x, y);
1913 self
1914 }
1915
1916 /// The pivot for paint-time transforms as a fraction of the element's
1917 /// rect (CSS `transform-origin`): `(0, 0)` top-left, `(0.5, 0.5)` center
1918 /// (the default). See [`Style::transform_origin`].
1919 pub fn transform_origin(mut self, fx: f32, fy: f32) -> Self {
1920 self.transform_origin = (fx, fy);
1921 self
1922 }
1923
1924 /// Draw progress for path elements (0 = nothing, 1 = full path).
1925 pub fn trim(mut self, v: f32) -> Self {
1926 self.path_trim = v.clamp(0.0, 1.0);
1927 self
1928 }
1929
1930 /// Frosted-glass backdrop blur: the content behind this (translucent)
1931 /// element is blurred by `radius` logical px before the fill composites
1932 /// over it. A non-positive radius clears it (no backdrop pass). See
1933 /// [`Style::backdrop_blur`].
1934 pub fn backdrop_blur(mut self, radius: f32) -> Self {
1935 self.backdrop_blur = (radius > 0.0).then_some(radius);
1936 self
1937 }
1938
1939 /// A foreground filter on this element's own content. See
1940 /// [`Style::element_filter`].
1941 pub fn element_filter(mut self, filter: ElementFilter) -> Self {
1942 self.element_filter = Some(filter);
1943 self
1944 }
1945
1946 // -- text --
1947
1948 /// Text size on the typographic scale.
1949 /// Free-form text size in logical px (overrides the scale token).
1950 pub fn size_px(mut self, px: f32) -> Self {
1951 self.text.size_px = Some(px);
1952 self
1953 }
1954
1955 /// Letter spacing in em (tracked-out editorial eyebrows etc.).
1956 pub fn tracking(mut self, em: f32) -> Self {
1957 self.text.letter_spacing = Some(em);
1958 self
1959 }
1960
1961 /// Line height as a multiple of the font size.
1962 pub fn leading(mut self, multiple: f32) -> Self {
1963 self.text.line_height = Some(multiple);
1964 self
1965 }
1966
1967 /// Tabular (fixed-width) numerals (`tnum`) — digits align in columns. For
1968 /// tables, timers, charts, and any numeric data that updates in place.
1969 pub fn tabular(mut self) -> Self {
1970 self.text.features.spacing = NumericSpacing::Tabular;
1971 self
1972 }
1973
1974 /// Proportional numerals — individually spaced for prose (`pnum`).
1975 pub fn proportional_nums(mut self) -> Self {
1976 self.text.features.spacing = NumericSpacing::Proportional;
1977 self
1978 }
1979
1980 /// Old-style / text figures (`onum`): ascending and descending digits
1981 /// that sit naturally in serif prose.
1982 pub fn oldstyle_nums(mut self) -> Self {
1983 self.text.features.figures = FigureStyle::OldStyle;
1984 self
1985 }
1986
1987 /// Lining figures (`lnum`): uniform cap-height digits for data and UI.
1988 pub fn lining_nums(mut self) -> Self {
1989 self.text.features.figures = FigureStyle::Lining;
1990 self
1991 }
1992
1993 /// Render lowercase letters as small capitals (`smcp`).
1994 pub fn small_caps(mut self) -> Self {
1995 self.text.features.small_caps = true;
1996 self
1997 }
1998
1999 /// Enable or disable standard ligatures (`liga`); most fonts default on.
2000 pub fn ligatures(mut self, on: bool) -> Self {
2001 self.text.features.ligatures = Some(on);
2002 self
2003 }
2004
2005 /// Common fractions (`frac`): `1/2` becomes a single fraction glyph.
2006 pub fn fractions(mut self) -> Self {
2007 self.text.features.fractions = true;
2008 self
2009 }
2010
2011 /// Font family role (Sans, Mono, or a registered Display/Serif face).
2012 pub fn family(mut self, family: crate::tokens::FamilyRole) -> Self {
2013 self.text.family = family;
2014 self
2015 }
2016
2017 pub fn size(mut self, size: crate::tokens::TextSize) -> Self {
2018 self.text.size = size;
2019 self
2020 }
2021
2022 /// Font weight.
2023 pub fn weight(mut self, weight: crate::tokens::Weight) -> Self {
2024 self.text.weight = weight;
2025 self
2026 }
2027
2028 /// Text color (defaults to the theme `text` role).
2029 pub fn color(mut self, color: Color) -> Self {
2030 self.text.color = Some(color);
2031 self
2032 }
2033
2034 /// Use the mono family role.
2035 pub fn mono(mut self) -> Self {
2036 self.text.family = crate::tokens::FamilyRole::Mono;
2037 self
2038 }
2039
2040 /// Truncate to one line with an ellipsis.
2041 pub fn truncate(mut self) -> Self {
2042 self.text.max_lines = Some(1);
2043 self
2044 }
2045
2046 /// Horizontal text alignment.
2047 pub fn text_align(mut self, align: TextAlign) -> Self {
2048 self.text.align = align;
2049 self
2050 }
2051
2052 /// Balance line lengths for this text ([`TextWrap::Balance`]) — even lines
2053 /// instead of a full-then-short ragged break. For headings and titles.
2054 pub fn balance(mut self) -> Self {
2055 self.text.wrap = TextWrap::Balance;
2056 self
2057 }
2058
2059 /// Avoid a stranded last word ([`TextWrap::Pretty`]) — best-effort for
2060 /// paragraphs; never adds a line and never makes the break worse.
2061 pub fn pretty(mut self) -> Self {
2062 self.text.wrap = TextWrap::Pretty;
2063 self
2064 }
2065
2066 /// Sets the line-breaking mode explicitly ([`TextWrap`]).
2067 pub fn text_wrap(mut self, wrap: TextWrap) -> Self {
2068 self.text.wrap = wrap;
2069 self
2070 }
2071
2072 /// Sets optical sizing explicitly ([`OpticalSizing`]) — how the `opsz`
2073 /// variation axis of a variable font is driven.
2074 pub fn optical(mut self, optical: OpticalSizing) -> Self {
2075 self.text.optical = optical;
2076 self
2077 }
2078
2079 /// Tracks the `opsz` axis to the rendered size ([`OpticalSizing::Auto`],
2080 /// CSS `font-optical-sizing: auto`) — small text gets the text-optical
2081 /// master, large sizes the display master. A no-op on static faces.
2082 pub fn optical_auto(mut self) -> Self {
2083 self.text.optical = OpticalSizing::Auto;
2084 self
2085 }
2086}
2087
2088impl Style {
2089 // -- grid --
2090
2091 /// Grid template columns (switches display to grid).
2092 pub fn grid_cols<T: Into<GridTemplate>>(mut self, tracks: impl IntoIterator<Item = T>) -> Self {
2093 self.display = Display::Grid;
2094 self.grid_template_columns = tracks.into_iter().map(Into::into).collect();
2095 self
2096 }
2097
2098 /// Grid template rows (switches display to grid). Accepts plain [`Track`]s or
2099 /// full [`GridTemplate`] entries (e.g. `repeat(...)`).
2100 pub fn grid_rows<T: Into<GridTemplate>>(mut self, tracks: impl IntoIterator<Item = T>) -> Self {
2101 self.display = Display::Grid;
2102 self.grid_template_rows = tracks.into_iter().map(Into::into).collect();
2103 self
2104 }
2105
2106 /// Places this element at a 1-based grid column, spanning `span` tracks.
2107 pub fn grid_col(mut self, start: i16, span: u16) -> Self {
2108 self.grid_column = GridPlace {
2109 start: Some(start),
2110 span: (span > 1).then_some(span),
2111 };
2112 self
2113 }
2114
2115 /// Places this element at a 1-based grid row, spanning `span` tracks.
2116 pub fn grid_row(mut self, start: i16, span: u16) -> Self {
2117 self.grid_row = GridPlace {
2118 start: Some(start),
2119 span: (span > 1).then_some(span),
2120 };
2121 self
2122 }
2123
2124 /// `grid-template-areas`: each row is a string of whitespace-separated area
2125 /// names, with `.` for an empty cell. Switches display to grid. Place
2126 /// children with [`Style::grid_area`]. Without explicit
2127 /// [`grid_cols`](Style::grid_cols)/[`grid_rows`](Style::grid_rows), an
2128 /// implicit grid of `auto` tracks matching the area shape is created.
2129 pub fn grid_template_areas<R: AsRef<str>>(mut self, rows: impl IntoIterator<Item = R>) -> Self {
2130 self.display = Display::Grid;
2131 self.grid_template_areas = rows
2132 .into_iter()
2133 .map(|row| {
2134 row.as_ref()
2135 .split_whitespace()
2136 .map(|cell| (cell != ".").then(|| cell.to_string()))
2137 .collect()
2138 })
2139 .collect();
2140 self
2141 }
2142
2143 /// Places this element in a named grid area (CSS `grid-area: name`),
2144 /// resolved against the parent's [`grid_template_areas`](Style::grid_template_areas).
2145 pub fn grid_area(mut self, name: impl Into<String>) -> Self {
2146 self.grid_area = Some(name.into());
2147 self
2148 }
2149
2150 /// Places this element's columns between two named grid lines
2151 /// (CSS `grid-column: start / end`).
2152 pub fn grid_col_lines(mut self, start: impl Into<String>, end: impl Into<String>) -> Self {
2153 self.grid_column_lines = GridLines {
2154 start: Some(start.into()),
2155 end: Some(end.into()),
2156 };
2157 self
2158 }
2159
2160 /// Places this element's rows between two named grid lines
2161 /// (CSS `grid-row: start / end`).
2162 pub fn grid_row_lines(mut self, start: impl Into<String>, end: impl Into<String>) -> Self {
2163 self.grid_row_lines = GridLines {
2164 start: Some(start.into()),
2165 end: Some(end.into()),
2166 };
2167 self
2168 }
2169
2170 /// Names the column grid lines positionally: the i-th name labels the
2171 /// (i+1)-th line. Reference them from [`grid_col_lines`](Style::grid_col_lines).
2172 pub fn grid_col_names<S: Into<String>>(mut self, names: impl IntoIterator<Item = S>) -> Self {
2173 self.grid_col_line_names = names.into_iter().map(|n| vec![n.into()]).collect();
2174 self
2175 }
2176
2177 /// Names the row grid lines positionally: the i-th name labels the (i+1)-th
2178 /// line. Reference them from [`grid_row_lines`](Style::grid_row_lines).
2179 pub fn grid_row_names<S: Into<String>>(mut self, names: impl IntoIterator<Item = S>) -> Self {
2180 self.grid_row_line_names = names.into_iter().map(|n| vec![n.into()]).collect();
2181 self
2182 }
2183}
2184
2185impl Style {
2186 /// True if any size constraint is expressed in `ch` (needs font metrics
2187 /// to resolve before taffy runs).
2188 pub(crate) fn has_ch(&self) -> bool {
2189 matches!(self.width, Length::Ch(_))
2190 || matches!(self.min_width, Length::Ch(_))
2191 || matches!(self.max_width, Length::Ch(_))
2192 || matches!(self.height, Length::Ch(_))
2193 || matches!(self.min_height, Length::Ch(_))
2194 || matches!(self.max_height, Length::Ch(_))
2195 || matches!(self.flex_basis, Length::Ch(_))
2196 }
2197
2198 /// Replaces every `Length::Ch(n)` size constraint with `Length::Px(n *
2199 /// ch_px)`, using the advance of `'0'` in this element's resolved text
2200 /// style. Called during `build` once font metrics are available.
2201 pub(crate) fn resolve_ch(&mut self, ch_px: f32) {
2202 self.width = self.width.resolved(ch_px);
2203 self.min_width = self.min_width.resolved(ch_px);
2204 self.max_width = self.max_width.resolved(ch_px);
2205 self.height = self.height.resolved(ch_px);
2206 self.min_height = self.min_height.resolved(ch_px);
2207 self.max_height = self.max_height.resolved(ch_px);
2208 self.flex_basis = self.flex_basis.resolved(ch_px);
2209 }
2210}
2211
2212#[cfg(test)]
2213mod corner_smoothing_tests {
2214 use super::*;
2215
2216 #[test]
2217 fn corner_smoothing_defaults_none_and_clamps() {
2218 // None ⇒ inherit the theme; the builder sets an explicit, clamped Some.
2219 assert_eq!(Style::default().corner_smoothing, None);
2220 assert_eq!(
2221 Style::default().corner_smoothing(0.6).corner_smoothing,
2222 Some(0.6)
2223 );
2224 assert_eq!(
2225 Style::default().corner_smoothing(5.0).corner_smoothing,
2226 Some(1.0)
2227 );
2228 assert_eq!(
2229 Style::default().corner_smoothing(-1.0).corner_smoothing,
2230 Some(0.0)
2231 );
2232 }
2233}
2234
2235#[cfg(test)]
2236mod feature_tests {
2237 use super::*;
2238
2239 /// The feature string of a style built with the given builders.
2240 fn fs(style: Style) -> Option<String> {
2241 style.text.features.feature_string()
2242 }
2243
2244 #[test]
2245 fn default_features_emit_nothing() {
2246 assert_eq!(FontFeatures::default().feature_string(), None);
2247 }
2248
2249 #[test]
2250 fn tabular_unchanged() {
2251 // Locks the exact prior `.tabular()` behavior (`"tnum" 1`), so every
2252 // existing golden that uses it stays byte-identical.
2253 assert_eq!(
2254 fs(Style::default().tabular()),
2255 Some("\"tnum\" 1".to_owned())
2256 );
2257 }
2258
2259 #[test]
2260 fn oldstyle_and_smcp() {
2261 let s = fs(Style::default().oldstyle_nums().small_caps()).unwrap();
2262 assert!(s.contains("\"onum\" 1"), "{s}");
2263 assert!(s.contains("\"smcp\" 1"), "{s}");
2264 assert!(!s.contains("\"lnum\""), "{s}");
2265 assert!(!s.contains("\"tnum\""), "{s}");
2266 assert!(!s.contains("\"pnum\""), "{s}");
2267 }
2268
2269 #[test]
2270 fn tnum_onum_mutually_consistent() {
2271 // Figure shape and figure spacing are orthogonal axes; both apply.
2272 let s = fs(Style::default().tabular().oldstyle_nums()).unwrap();
2273 assert!(s.contains("\"tnum\" 1"), "{s}");
2274 assert!(s.contains("\"onum\" 1"), "{s}");
2275 }
2276
2277 #[test]
2278 fn ligatures_off_and_on() {
2279 assert!(
2280 fs(Style::default().ligatures(false))
2281 .unwrap()
2282 .contains("\"liga\" 0")
2283 );
2284 assert!(
2285 fs(Style::default().ligatures(true))
2286 .unwrap()
2287 .contains("\"liga\" 1")
2288 );
2289 }
2290
2291 #[test]
2292 fn fractions_and_proportional() {
2293 assert!(
2294 fs(Style::default().fractions())
2295 .unwrap()
2296 .contains("\"frac\" 1")
2297 );
2298 assert!(
2299 fs(Style::default().proportional_nums())
2300 .unwrap()
2301 .contains("\"pnum\" 1")
2302 );
2303 }
2304
2305 #[test]
2306 fn figure_axis_is_exclusive() {
2307 // The figure axis is one slot: the last builder wins.
2308 let style = Style::default().oldstyle_nums().lining_nums();
2309 assert_eq!(style.text.features.figures, FigureStyle::Lining);
2310 let s = fs(style).unwrap();
2311 assert!(s.contains("\"lnum\""), "{s}");
2312 assert!(!s.contains("\"onum\""), "{s}");
2313 }
2314}
2315
2316#[cfg(test)]
2317mod gradient_tests {
2318 use super::*;
2319 use crate::oklch_of;
2320 use crate::theme::Theme;
2321 use crate::tokens::GRADIENT_STEPS;
2322
2323 #[test]
2324 fn midpoint_keeps_chroma_no_gray_deadzone() {
2325 // A wide-hue transition (accent ~262° → warning ~80°): the naive sRGB
2326 // average of the two anchors collapses toward gray, but the OKLCH
2327 // midpoint stays vivid.
2328 let theme = Theme::light();
2329 let a = theme.accent;
2330 let b = theme.warning.solid;
2331 let stops = oklch_stops(&[(0.0, a), (1.0, b)], GRADIENT_STEPS);
2332 let mid = stops
2333 .iter()
2334 .min_by(|x, y| (x.offset - 0.5).abs().total_cmp(&(y.offset - 0.5).abs()))
2335 .unwrap();
2336 let ca = a.components;
2337 let cb = b.components;
2338 let srgb_mid = Color::new([
2339 (ca[0] + cb[0]) / 2.0,
2340 (ca[1] + cb[1]) / 2.0,
2341 (ca[2] + cb[2]) / 2.0,
2342 (ca[3] + cb[3]) / 2.0,
2343 ]);
2344 let c_oklch = oklch_of(mid.color)[1];
2345 let c_srgb = oklch_of(srgb_mid)[1];
2346 assert!(
2347 c_oklch > 1.5 * c_srgb,
2348 "OKLCH mid chroma {c_oklch} should far exceed sRGB mid chroma {c_srgb}"
2349 );
2350 }
2351
2352 #[test]
2353 fn lightness_is_monotonic_across_stops() {
2354 // A9-style same-hue ramp (A7 L 0.725 → A10 L 0.545), fully in gamut:
2355 // lightness must never reverse (no dark bump mid-ramp). The epsilon
2356 // absorbs per-channel gamut-clamp noise.
2357 let theme = Theme::light();
2358 let a = theme.accents.step(7);
2359 let b = theme.accents.step(10);
2360 let stops = oklch_stops(&[(0.0, a), (1.0, b)], GRADIENT_STEPS);
2361 for w in stops.windows(2) {
2362 let l0 = oklch_of(w[0].color)[0];
2363 let l1 = oklch_of(w[1].color)[0];
2364 assert!(l1 <= l0 + 1e-3, "lightness rose mid-ramp: {l0} -> {l1}");
2365 }
2366 }
2367
2368 #[test]
2369 fn offsets_sorted_and_span_anchors() {
2370 let theme = Theme::light();
2371 let a = theme.accent;
2372 let b = theme.warning.solid;
2373 let stops = oklch_stops(&[(0.0, a), (1.0, b)], 16);
2374 assert_eq!(stops.len(), 17);
2375 assert_eq!(stops.first().unwrap().offset, 0.0);
2376 assert_eq!(stops.last().unwrap().offset, 1.0);
2377 for w in stops.windows(2) {
2378 assert!(w[1].offset > w[0].offset, "offsets must strictly increase");
2379 }
2380 // Unsorted anchors must produce the identical result.
2381 let unsorted = oklch_stops(&[(1.0, b), (0.0, a)], 16);
2382 assert_eq!(unsorted, stops);
2383 }
2384
2385 #[test]
2386 fn endpoints_are_exact() {
2387 // Pre-expansion must never shift the anchors themselves.
2388 let theme = Theme::light();
2389 let a = theme.accent;
2390 let b = theme.warning.solid;
2391 let stops = oklch_stops(&[(0.0, a), (1.0, b)], 16);
2392 assert_eq!(stops.first().unwrap().color.to_rgba8(), a.to_rgba8());
2393 assert_eq!(stops.last().unwrap().color.to_rgba8(), b.to_rgba8());
2394 }
2395
2396 #[test]
2397 fn linear_gradient_even_spacing() {
2398 let theme = Theme::light();
2399 let (a, b, c) = (theme.accent, theme.warning.solid, theme.success.solid);
2400 let paint = linear_gradient(90.0, [a, b, c]);
2401 let Paint::LinearGradient { angle_deg, stops } = paint else {
2402 panic!("linear_gradient must build a LinearGradient");
2403 };
2404 assert_eq!(angle_deg, 90.0);
2405 // Three colors land evenly at 0.0 / 0.5 / 1.0, each carrying its anchor.
2406 for (off, color) in [(0.0, a), (0.5, b), (1.0, c)] {
2407 let found = stops
2408 .iter()
2409 .find(|s| (s.offset - off).abs() < 1e-4)
2410 .unwrap_or_else(|| panic!("no stop at offset {off}"));
2411 assert_eq!(
2412 found.color.to_rgba8(),
2413 color.to_rgba8(),
2414 "anchor color at offset {off}"
2415 );
2416 }
2417 }
2418
2419 #[test]
2420 fn degenerate_inputs() {
2421 let theme = Theme::light();
2422 let a = theme.accent;
2423 let b = theme.warning.solid;
2424 // Empty anchors → empty.
2425 assert!(oklch_stops(&[], 16).is_empty());
2426 // Single anchor → one stop at its offset.
2427 let single = oklch_stops(&[(0.3, a)], 16);
2428 assert_eq!(single.len(), 1);
2429 assert_eq!(single[0].offset, 0.3);
2430 assert_eq!(single[0].color.to_rgba8(), a.to_rgba8());
2431 // steps == 0 → exactly the two endpoints, no interpolation.
2432 let zero_steps = oklch_stops(&[(0.0, a), (1.0, b)], 0);
2433 assert_eq!(zero_steps.len(), 2);
2434 assert_eq!(zero_steps[0].color.to_rgba8(), a.to_rgba8());
2435 assert_eq!(zero_steps[1].color.to_rgba8(), b.to_rgba8());
2436 }
2437
2438 #[test]
2439 fn optical_sizing_resolves_opsz_per_mode() {
2440 // Default emits no axis at any size; Auto tracks the rendered px;
2441 // Fixed pins a value (clamped non-negative) regardless of size.
2442 assert_eq!(OpticalSizing::Default.opsz_at(16.0), None);
2443 assert_eq!(OpticalSizing::Default.opsz_at(48.0), None);
2444 assert_eq!(OpticalSizing::Auto.opsz_at(16.0), Some(16.0));
2445 assert_eq!(OpticalSizing::Auto.opsz_at(48.0), Some(48.0));
2446 assert_eq!(OpticalSizing::Fixed(72.0).opsz_at(16.0), Some(72.0));
2447 assert_eq!(OpticalSizing::Fixed(72.0).opsz_at(48.0), Some(72.0));
2448 // Negatives clamp to 0 (a valid axis floor, never a negative coord).
2449 assert_eq!(OpticalSizing::Fixed(-5.0).opsz_at(16.0), Some(0.0));
2450 // Unresolved `Inherit` falls back to `Auto` (the stock theme value).
2451 assert_eq!(OpticalSizing::Inherit.opsz_at(16.0), Some(16.0));
2452 // The default of the typed value is `Inherit` (follows the theme).
2453 assert_eq!(OpticalSizing::default(), OpticalSizing::Inherit);
2454 }
2455
2456 #[test]
2457 fn optical_builders_set_the_axis() {
2458 // The ergonomic builders flow into the text style group.
2459 assert_eq!(Style::default().text.optical, OpticalSizing::Inherit);
2460 assert_eq!(
2461 Style::default().optical_auto().text.optical,
2462 OpticalSizing::Auto
2463 );
2464 assert_eq!(
2465 Style::default()
2466 .optical(OpticalSizing::Fixed(60.0))
2467 .text
2468 .optical,
2469 OpticalSizing::Fixed(60.0)
2470 );
2471 }
2472}