Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/eased-gradients.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@gpuix/native": minor
"@gpuix/react": minor
---

Ease the mix between two gradient stops.

An `<easing-function>` between two colour stops bends the mix, following the
CSSWG proposal in csswg-drafts issue 1332: `linear-gradient(to top, black,
ease-in-out, transparent)`. `ease`, `ease-in`, `ease-out`, `ease-in-out` and
`cubic-bezier()` are read. The easing paints as the GPUI colour hint whose
curve crosses one half at the same place, so the paint agrees with the easing
at both ends and at the half-way point. A straight fade to transparent looks
dense near the solid stop and thin near the clear one. An eased one reads as
one smooth fall-off.
2 changes: 2 additions & 0 deletions examples/demo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { TestRoot } from "@gpuix/react/testing"
import { App, BASE, PALETTES } from "./demo/app"
import { ClassNames } from "./demo/class-names"
import { Colors } from "./demo/colors"
import { Gradients } from "./demo/gradients"
import { Inheritance } from "./demo/inheritance"
import { Lengths } from "./demo/lengths"
import { motion } from "@gpuix/react"
Expand All @@ -39,6 +40,7 @@ function root(): TestRoot {

const PANELS = [
["colors", <Colors />],
["gradients", <Gradients />],
["lengths", <Lengths />],
["variables", <Variables />],
["inheritance", <Inheritance />],
Expand Down
16 changes: 15 additions & 1 deletion examples/demo/gradients.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ const ALPHA: Array<[string, string]> = [
["linear-gradient(to right, currentColor, transparent)", "currentColor as a stop"],
]

const EASING: Array<[string, string]> = [
["linear-gradient(to right, #ff5c8a, #5cc8ff)", "no easing: a straight mix"],
["linear-gradient(to right, #ff5c8a, ease-in-out, #5cc8ff)", "ease-in-out holds both ends longer"],
["linear-gradient(to right, #ff5c8a, ease-in, #5cc8ff)", "ease-in keeps the first colour"],
["linear-gradient(to right, #ff5c8a, cubic-bezier(0.7, 0, 0.3, 1), #5cc8ff)", "any cubic-bezier()"],
["linear-gradient(to top, black, transparent)", "a straight scrim: dense at the bottom, a hard edge at the top"],
["linear-gradient(to top, black, ease-in-out, transparent)", "the same scrim eased"],
]

function List({ title, note, entries }: {
title: string
note: string
Expand Down Expand Up @@ -63,9 +72,14 @@ export function Gradients() {
note="Up to eight stops. Missing positions spread evenly, and a position that steps back snaps to the one before it."
entries={STOPS}
/>
<List
title="Easing"
note="An easing function between two stops bends the mix. CSS has no such thing yet, so this follows the CSSWG proposal (issue 1332). A straight fade to transparent looks dense near the solid stop and thin near the clear one. An eased one reads as one smooth fall-off."
entries={EASING}
/>
<List
title="Alpha"
note="A stop can be see-through. The gradient is the one fill of the box, so it paints over the parent, not over a backgroundColor on the same box."
note="A stop can be see-through. A backgroundImage replaces the backgroundColor of the same box, and where the gradient is clear the parent shows through."
entries={ALPHA}
Comment thread
mateo-m marked this conversation as resolved.
/>
</div>
Expand Down
150 changes: 143 additions & 7 deletions packages/native/css/src/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,76 @@ use lightningcss::values::position::{HorizontalPositionKeyword, VerticalPosition
use crate::color::{self, ColorContext, Rgba};
use crate::CssError;

/// An easing between two stops: the control points `[x1, y1, x2, y2]` of a
/// cubic bezier from (0, 0) to (1, 1). All zero means none, a straight mix.
///
/// CSS has no easing in gradients yet. This is the syntax the CSSWG proposal
/// (csswg-drafts issue 1332) uses: an `<easing-function>` in the place of a
/// colour hint, between two colour stops.
pub type Easing = [f32; 4];

/// Read one `<easing-function>` from CSS Easing 1. `linear` reads as none.
pub fn easing(text: &str) -> Option<Easing> {
let lower = text.trim().to_ascii_lowercase();
match lower.as_str() {
"linear" => return Some([0.0; 4]),
"ease" => return Some([0.25, 0.1, 0.25, 1.0]),
"ease-in" => return Some([0.42, 0.0, 1.0, 1.0]),
"ease-out" => return Some([0.0, 0.0, 0.58, 1.0]),
"ease-in-out" => return Some([0.42, 0.0, 0.58, 1.0]),
_ => {}
}
let inner = lower.strip_prefix("cubic-bezier(")?.strip_suffix(')')?;
let numbers: Vec<f32> = inner
.split(',')
.map(|n| n.trim().parse::<f32>().ok().filter(|n| n.is_finite()))
.collect::<Option<_>>()?;
let [x1, y1, x2, y2] = numbers[..] else { return None };
let unit = 0.0..=1.0;
(unit.contains(&x1) && unit.contains(&x2)).then_some([x1, y1, x2, y2])
}

/// Split at the commas outside parentheses.
fn split_top_level(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut depth = 0i32;
let mut start = 0;
for (i, c) in text.char_indices() {
match c {
'(' => depth += 1,
')' => depth = (depth - 1).max(0),
',' if depth == 0 => {
out.push(&text[start..i]);
start = i + 1;
}
_ => {}
}
}
out.push(&text[start..]);
out
}

/// Pull the easings out of a `linear-gradient()` so lightningcss can read
/// the rest. Returns the value without them, how many arguments stay, and
/// each easing with the index of the argument that follows it.
fn split_easings(value: &str) -> Option<(String, usize, Vec<(usize, Easing)>)> {
let open = value.find('(')?;
let close = value.rfind(')')?;
let head = &value[..open];
if !head.trim().eq_ignore_ascii_case("linear-gradient") {
return None;
}
let mut kept = Vec::new();
let mut easings = Vec::new();
for piece in split_top_level(&value[open + 1..close]) {
match easing(piece) {
Some(easing) => easings.push((kept.len(), easing)),
None => kept.push(piece.trim()),
}
}
Some((format!("{head}({})", kept.join(", ")), kept.len(), easings))
}

/// Where the line of a linear gradient points.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Line {
Expand All @@ -39,6 +109,8 @@ pub struct Stop {
/// Where between this stop and the next the mix is half way, as a
/// fraction of that span. 0 means no hint.
pub hint: f32,
/// The easing to the next stop. All zero is none.
pub easing: Easing,
Comment thread
mateo-m marked this conversation as resolved.
}

/// A `linear-gradient()` ready to paint.
Expand Down Expand Up @@ -76,7 +148,9 @@ pub struct Reading {

/// Read one background value. `none` reads as `Ok(None)`.
pub fn read(value: &str, context: &ColorContext) -> Result<Option<Reading>, CssError> {
let Ok(image) = Image::parse_string(value) else {
let (parsed, kept, easings) =
split_easings(value).unwrap_or_else(|| (value.to_string(), 0, Vec::new()));
let Ok(image) = Image::parse_string(&parsed) else {
let reading = color::read(value, context)?;
return Ok(Some(Reading {
fill: Fill::Color(reading.color),
Expand All @@ -88,7 +162,15 @@ pub fn read(value: &str, context: &ColorContext) -> Result<Option<Reading>, CssE
Image::Gradient(gradient) => match *gradient {
Gradient::Linear(linear) => {
let line = line_of(&linear.direction);
let (stops, read_current_color) = fix_up(&linear.items, context, value)?;
// The direction, when written, is the one argument that is
// not an item. An easing sits after the item before it.
let offset = kept - linear.items.len();
let easings = easings
.iter()
.map(|(index, easing)| (index.checked_sub(offset + 1), *easing))
.collect::<Vec<_>>();
let (stops, read_current_color) =
fix_up(&linear.items, &easings, context, value)?;
Ok(Some(Reading {
fill: Fill::LinearGradient(LinearGradient { line, stops }),
read_current_color,
Expand Down Expand Up @@ -140,6 +222,7 @@ type Item = GradientItem<lightningcss::values::length::LengthPercentage>;
struct Pending {
color: Option<Rgba>,
position: Option<f32>,
easing: Easing,
}

/// Turn the parsed items into stops with positions, the way CSS Images 3
Expand All @@ -153,6 +236,7 @@ struct Pending {
/// the stop after it.
fn fix_up(
items: &[Item],
easings: &[(Option<usize>, Easing)],
context: &ColorContext,
value: &str,
) -> Result<(Vec<Stop>, bool), CssError> {
Expand All @@ -169,19 +253,33 @@ fn fix_up(
.as_ref()
.map(|p| fraction(p, value))
.transpose()?,
easing: [0.0; 4],
});
}
GradientItem::Hint(position) => pending.push(Pending {
color: None,
position: Some(fraction(position, value)?),
easing: [0.0; 4],
}),
}
}
let bad_value = || CssError::BadValue {
property: "background".to_string(),
value: value.to_string(),
};
if pending.len() < 2 {
return Err(CssError::BadValue {
property: "background".to_string(),
value: value.to_string(),
});
return Err(bad_value());
}
// An easing goes between two colour stops, one per pair, and not next
// to a hint, which already says where the half-way point is.
for (index, easing) in easings {
let Some(index) = *index else { return Err(bad_value()) };
let both_colours = pending.get(index).is_some_and(|p| p.color.is_some())
&& pending.get(index + 1).is_some_and(|p| p.color.is_some());
if !both_colours || pending[index].easing != [0.0; 4] {
return Err(bad_value());
}
pending[index].easing = *easing;
}

let last = pending.len() - 1;
Expand Down Expand Up @@ -216,7 +314,12 @@ fn fix_up(
for (i, item) in pending.iter().enumerate() {
let position = item.position.unwrap();
match item.color {
Some(color) => stops.push(Stop { color, position, hint: 0.0 }),
Some(color) => stops.push(Stop {
color,
position,
hint: 0.0,
easing: item.easing,
}),
None => {
let Some(previous) = stops.last_mut() else { continue };
let next = pending[i + 1..]
Expand Down Expand Up @@ -293,6 +396,39 @@ mod tests {
assert_eq!(gradient.stops[1].hint, 0.0);
}

#[test]
fn reads_an_easing_between_two_stops() {
let read = gradient("linear-gradient(to right, red, ease-in-out, blue)");
assert_eq!(read.stops.len(), 2);
assert_eq!(read.stops[0].easing, [0.42, 0.0, 0.58, 1.0]);
assert_eq!(read.stops[1].easing, [0.0; 4]);

let read =
gradient("linear-gradient(red, cubic-bezier(0.5, 0, 1, 1.5), blue 80%, green)");
assert_eq!(read.stops[0].easing, [0.5, 0.0, 1.0, 1.5]);
assert_eq!(read.stops[1].position, 0.8);
assert_eq!(read.stops[1].easing, [0.0; 4]);

// `linear` is the straight mix, which is what no easing does.
let read = gradient("linear-gradient(red, linear, blue)");
assert_eq!(read.stops[0].easing, [0.0; 4]);
}

#[test]
fn an_easing_needs_a_stop_on_each_side() {
let context = ColorContext::default();
for bad in [
"linear-gradient(ease-in, red, blue)",
"linear-gradient(red, blue, ease-in)",
"linear-gradient(red, ease-in, ease-out, blue)",
"linear-gradient(red, ease-in, 30%, blue)",
"linear-gradient(red, 30%, ease-in, blue)",
"linear-gradient(red, cubic-bezier(2, 0, 1, 1), blue)",
] {
assert!(read(bad, &context).is_err(), "{bad}");
}
}

#[test]
fn reads_every_direction() {
assert_eq!(gradient("linear-gradient(red, blue)").line, Line::Angle(180.0));
Expand Down
77 changes: 76 additions & 1 deletion packages/native/src/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,62 @@ pub(crate) fn to_background(fill: &gpuix_css::background::Fill) -> gpui::Backgro
.map(|stop| gpui::LinearColorStop {
color: to_hsla(stop.color),
percentage: stop.position,
hint: stop.hint,
hint: hint_for(stop),
})
.collect();
gpui::linear_gradient_stops(line, &stops)
}
}
}

/// The hint GPUI paints for one stop.
///
/// GPUI has no easing in a gradient, but its hint moves the half-way point of
/// the mix along the CSS exponential curve. An easing becomes the hint where
/// its own curve crosses one half. The two curves then agree at both ends and
/// at the half-way point, which is as close as one number gets.
fn hint_for(stop: &gpuix_css::background::Stop) -> f32 {
if stop.hint != 0.0 || stop.easing == [0.0; 4] {
return stop.hint;
}
easing_half_point(stop.easing)
}

/// The x at which a cubic bezier easing's output crosses one half.
///
/// The curve runs from (0, 0) to (1, 1) with the control points
/// `[x1, y1, x2, y2]`. The output can overshoot, so this walks to the first
/// crossing and then bisects.
fn easing_half_point(easing: [f32; 4]) -> f32 {
let [x1, y1, x2, y2] = easing;
let at = |a: f32, b: f32, t: f32| {
let u = 1.0 - t;
3.0 * u * u * t * a + 3.0 * u * t * t * b + t * t * t
};
let y = |t: f32| at(y1, y2, t);
let mut low = 0.0f32;
let mut high = 1.0f32;
for step in 1..=64 {
let t = step as f32 / 64.0;
if y(t) >= 0.5 {
high = t;
low = t - 1.0 / 64.0;
break;
}
}
for _ in 0..24 {
let mid = (low + high) / 2.0;
if y(mid) < 0.5 {
low = mid;
} else {
high = mid;
}
}
let x = at(x1, x2, (low + high) / 2.0);
// The shader reads a hint of 0 or 1 as none, so keep the value inside.
x.clamp(0.001, 0.999)
}

/// Read a colour that depends on the element or the window.
///
/// `currentColor` and `light-dark()` both need context, so this is the entry
Expand Down Expand Up @@ -237,4 +285,31 @@ mod tests {
Some(u32::from(rgba))
);
}

#[test]
fn an_easing_becomes_the_hint_at_its_half_point() {
// ease-in-out is symmetric, so its half point is the middle, which is
// the same paint as no hint at all.
let middle = easing_half_point([0.42, 0.0, 0.58, 1.0]);
assert!((middle - 0.5).abs() < 0.01, "got {middle}");

// ease-in holds the first colour longer, so the half point sits late.
let late = easing_half_point([0.42, 0.0, 1.0, 1.0]);
assert!(late > 0.6, "got {late}");

// cubic-bezier(0, 1, 0, 1) jumps toward the second colour at once.
let early = easing_half_point([0.0, 1.0, 0.0, 1.0]);
assert!(early < 0.05, "got {early}");
}

#[test]
fn an_explicit_hint_wins_over_the_easing() {
let stop = gpuix_css::background::Stop {
color: Rgba { r: 1.0, g: 0.0, b: 0.0, a: 1.0 },
position: 0.0,
hint: 0.25,
easing: [0.42, 0.0, 1.0, 1.0],
};
assert_eq!(hint_for(&stop), 0.25);
}
}
Loading
Loading