Add support for CSS conic-gradient 'from <angle>' syntax

Implement rotation support for conic gradients by adding the 'from <angle>'
syntax, which rotates the entire gradient by the specified angle.

- Add `from_angle` field to ConicGradient expression
- Parse 'from <angle>' syntax in compiler (defaults to 0deg when omitted)
- Normalize angles to 0-1 range (0.0 = 0°, 1.0 = 360°)
- Add `ConicGradientBrush::rotated_stops()` method that:
  * Applies rotation by adding from_angle to each stop position
  * Adds boundary stops at 0.0 and 1.0 with interpolated colors
  * Handles stops outside [0, 1] range for boundary interpolation
- Update all renderers (Skia, FemtoVG, Qt, Software) to use rotated_stops()

The rotation is applied at render time by the rotated_stops() method,
which ensures all renderers consistently handle the gradient rotation.
This commit is contained in:
Tasuku Suzuki 2025-10-23 23:06:18 +09:00
parent 9f4b3cd778
commit 820ae2b041
22 changed files with 411 additions and 137 deletions

View file

@ -105,33 +105,37 @@ class ConicGradientBrush
public:
/// Constructs an empty conic gradient with no color stops.
ConicGradientBrush() = default;
/// Constructs a new conic gradient. The color stops will be
/// Constructs a new conic gradient with the specified starting \a angle. The color stops will be
/// constructed from the stops array pointed to be \a firstStop, with the length \a stopCount.
ConicGradientBrush(const GradientStop *firstStop, int stopCount)
: inner(make_conic_gradient(firstStop, stopCount))
ConicGradientBrush(float angle, const GradientStop *firstStop, int stopCount)
: inner(make_conic_gradient(angle, firstStop, stopCount))
{
}
/// Returns the conic gradient's starting angle (rotation) in degrees.
float angle() const { return inner.from_angle; }
/// Returns the number of gradient stops.
int stopCount() const { return int(inner.size()); }
int stopCount() const { return int(inner.stops.size()); }
/// Returns a pointer to the first gradient stop; undefined if the gradient has not stops.
const GradientStop *stopsBegin() const { return inner.begin(); }
const GradientStop *stopsBegin() const { return inner.stops.begin(); }
/// Returns a pointer past the last gradient stop. The returned pointer cannot be dereferenced,
/// it can only be used for comparison.
const GradientStop *stopsEnd() const { return inner.end(); }
const GradientStop *stopsEnd() const { return inner.stops.end(); }
private:
cbindgen_private::types::ConicGradientBrush inner;
friend class slint::Brush;
static SharedVector<private_api::GradientStop>
make_conic_gradient(const GradientStop *firstStop, int stopCount)
static cbindgen_private::types::ConicGradientBrush
make_conic_gradient(float angle, const GradientStop *firstStop, int stopCount)
{
SharedVector<private_api::GradientStop> gradient;
cbindgen_private::types::ConicGradientBrush gradient;
gradient.from_angle = angle;
for (int i = 0; i < stopCount; ++i, ++firstStop)
gradient.push_back(*firstStop);
gradient.stops.push_back(*firstStop);
return gradient;
}
};
@ -223,8 +227,8 @@ Color Brush::color() const
}
break;
case Tag::ConicGradient:
if (data.conic_gradient._0.size() > 0) {
result.inner = data.conic_gradient._0[0].color;
if (data.conic_gradient._0.stops.size() > 0) {
result.inner = data.conic_gradient._0.stops[0].color;
}
break;
}
@ -252,9 +256,9 @@ inline Brush Brush::brighter(float factor) const
}
break;
case Tag::ConicGradient:
for (std::size_t i = 0; i < data.conic_gradient._0.size(); ++i) {
cbindgen_private::types::slint_color_brighter(&data.conic_gradient._0[i].color, factor,
&result.data.conic_gradient._0[i].color);
for (std::size_t i = 0; i < data.conic_gradient._0.stops.size(); ++i) {
cbindgen_private::types::slint_color_brighter(&data.conic_gradient._0.stops[i].color, factor,
&result.data.conic_gradient._0.stops[i].color);
}
break;
}
@ -282,9 +286,9 @@ inline Brush Brush::darker(float factor) const
}
break;
case Tag::ConicGradient:
for (std::size_t i = 0; i < data.conic_gradient._0.size(); ++i) {
cbindgen_private::types::slint_color_darker(&data.conic_gradient._0[i].color, factor,
&result.data.conic_gradient._0[i].color);
for (std::size_t i = 0; i < data.conic_gradient._0.stops.size(); ++i) {
cbindgen_private::types::slint_color_darker(&data.conic_gradient._0.stops[i].color, factor,
&result.data.conic_gradient._0.stops[i].color);
}
break;
}
@ -314,10 +318,10 @@ inline Brush Brush::transparentize(float factor) const
}
break;
case Tag::ConicGradient:
for (std::size_t i = 0; i < data.conic_gradient._0.size(); ++i) {
for (std::size_t i = 0; i < data.conic_gradient._0.stops.size(); ++i) {
cbindgen_private::types::slint_color_transparentize(
&data.conic_gradient._0[i].color, factor,
&result.data.conic_gradient._0[i].color);
&data.conic_gradient._0.stops[i].color, factor,
&result.data.conic_gradient._0.stops[i].color);
}
break;
}
@ -347,10 +351,10 @@ inline Brush Brush::with_alpha(float alpha) const
}
break;
case Tag::ConicGradient:
for (std::size_t i = 0; i < data.conic_gradient._0.size(); ++i) {
for (std::size_t i = 0; i < data.conic_gradient._0.stops.size(); ++i) {
cbindgen_private::types::slint_color_with_alpha(
&data.conic_gradient._0[i].color, alpha,
&result.data.conic_gradient._0[i].color);
&data.conic_gradient._0.stops[i].color, alpha,
&result.data.conic_gradient._0.stops[i].color);
}
break;
}

View file

@ -167,12 +167,16 @@ export component Example inherits Window {
Conic gradients are gradients where the color transitions rotate around a center point (like the angle on a color wheel).
To describe a conic gradient, use the `@conic-gradient` macro with the following signature:
### @conic-gradient(color angle, color angle, ...)
### @conic-gradient([from angle,] color angle, color angle, ...)
The conic gradient is described by a series of color stops, each consisting of a color and an angle.
The angle specifies where the color is placed along the circular sweep (0deg to 360deg).
Colors are interpolated between the stops along the circular path.
The optional `from` parameter specifies the starting angle of the gradient rotation.
If omitted, the gradient starts at 0deg (pointing upward). For example, `from 90deg` rotates
the entire gradient 90 degrees clockwise.
Example:
<CodeSnippetMD imagePath="/src/assets/generated/gradients-conic.png" scale="3" imageWidth="200" imageHeight="200" imageAlt='Conic Gradient Example'>
@ -189,6 +193,20 @@ export component Example inherits Window {
This creates a color wheel effect with red at the top (0deg/360deg), green at 120 degrees, and blue at 240 degrees.
You can also rotate the gradient using the `from` parameter:
```slint
export component Example inherits Window {
preferred-width: 100px;
preferred-height: 100px;
Rectangle {
background: @conic-gradient(from 90deg, #f00 0deg, #0f0 120deg, #00f 240deg, #f00 360deg);
}
}
```
This rotates the same color wheel 90 degrees clockwise, so red starts at the right (90deg) instead of the top.
:::note[Known Limitation]
Negative angles cannot be used directly in conic gradients (e.g., `#ff0000 -90deg`).
Instead, use one of these workarounds:

View file

@ -565,8 +565,9 @@ fn into_qbrush(
return qcg;
}
};
let count = g.stops().count();
for (idx, s) in g.stops().enumerate() {
let rotated = g.rotated_stops();
let count = rotated.len();
for (idx, s) in rotated.iter().enumerate() {
// Qt's conical gradient goes counter-clockwise, but Slint expects clockwise
// So we need to invert the positions: Qt position = 1.0 - Slint position
let pos: f32 = 1.0 - mangle_position(s.position, idx, count);

View file

@ -733,6 +733,8 @@ pub enum Expression {
},
ConicGradient {
/// The starting angle (rotation) of the gradient, corresponding to CSS `from <angle>`
from_angle: Box<Expression>,
/// First expression in the tuple is a color, second expression is the stop angle
stops: Vec<(Expression, Expression)>,
},
@ -968,7 +970,8 @@ impl Expression {
visitor(s);
}
}
Expression::ConicGradient { stops } => {
Expression::ConicGradient { from_angle, stops } => {
visitor(from_angle);
for (c, s) in stops {
visitor(c);
visitor(s);
@ -1071,7 +1074,8 @@ impl Expression {
visitor(s);
}
}
Expression::ConicGradient { stops } => {
Expression::ConicGradient { from_angle, stops } => {
visitor(from_angle);
for (c, s) in stops {
visitor(c);
visitor(s);
@ -1168,8 +1172,9 @@ impl Expression {
Expression::RadialGradient { stops } => {
stops.iter().all(|(c, s)| c.is_constant(ga) && s.is_constant(ga))
}
Expression::ConicGradient { stops } => {
stops.iter().all(|(c, s)| c.is_constant(ga) && s.is_constant(ga))
Expression::ConicGradient { from_angle, stops } => {
from_angle.is_constant(ga)
&& stops.iter().all(|(c, s)| c.is_constant(ga) && s.is_constant(ga))
}
Expression::EnumerationValue(_) => true,
Expression::ReturnStatement(expr) => {
@ -1792,14 +1797,11 @@ pub fn pretty_print(f: &mut dyn std::fmt::Write, expression: &Expression) -> std
}
write!(f, ")")
}
Expression::ConicGradient { stops } => {
write!(f, "@conic-gradient(")?;
let mut first = true;
Expression::ConicGradient { from_angle, stops } => {
write!(f, "@conic-gradient(from ")?;
pretty_print(f, from_angle)?;
for (c, s) in stops {
if !first {
write!(f, ", ")?;
}
first = false;
write!(f, ", ")?;
pretty_print(f, c)?;
write!(f, " ")?;
pretty_print(f, s)?;

View file

@ -3464,7 +3464,7 @@ fn compile_expression(expr: &llr::Expression, ctx: &EvaluationContext) -> String
stops_it.join(", "), angle, stops.len()
)
}
Expression::RadialGradient{ stops} => {
Expression::RadialGradient{ stops } => {
let mut stops_it = stops.iter().map(|(color, stop)| {
let color = compile_expression(color, ctx);
let position = compile_expression(stop, ctx);
@ -3475,15 +3475,16 @@ fn compile_expression(expr: &llr::Expression, ctx: &EvaluationContext) -> String
stops_it.join(", "), stops.len()
)
}
Expression::ConicGradient{ stops} => {
Expression::ConicGradient{ from_angle, stops } => {
let from_angle = compile_expression(from_angle, ctx);
let mut stops_it = stops.iter().map(|(color, stop)| {
let color = compile_expression(color, ctx);
let position = compile_expression(stop, ctx);
format!("slint::private_api::GradientStop{{ {color}, float({position}), }}")
});
format!(
"[&] {{ const slint::private_api::GradientStop stops[] = {{ {} }}; return slint::Brush(slint::private_api::ConicGradientBrush(stops, {})); }}()",
stops_it.join(", "), stops.len()
"[&] {{ const slint::private_api::GradientStop stops[] = {{ {} }}; return slint::Brush(slint::private_api::ConicGradientBrush(float({}), stops, {})); }}()",
stops_it.join(", "), from_angle, stops.len()
)
}
Expression::EnumerationValue(value) => {

View file

@ -2732,14 +2732,15 @@ fn compile_expression(expr: &Expression, ctx: &EvaluationContext) -> TokenStream
sp::RadialGradientBrush::new_circle([#(#stops),*])
))
}
Expression::ConicGradient { stops } => {
Expression::ConicGradient { from_angle, stops } => {
let from_angle = compile_expression(from_angle, ctx);
let stops = stops.iter().map(|(color, stop)| {
let color = compile_expression(color, ctx);
let position = compile_expression(stop, ctx);
quote!(sp::GradientStop{ color: #color, position: #position as _ })
});
quote!(slint::Brush::ConicGradient(
sp::ConicGradientBrush::new([#(#stops),*])
sp::ConicGradientBrush::new(#from_angle as _, [#(#stops),*])
))
}
Expression::EnumerationValue(value) => {

View file

@ -159,6 +159,8 @@ pub enum Expression {
},
ConicGradient {
/// The starting angle (rotation) of the gradient, corresponding to CSS `from <angle>`
from_angle: Box<Expression>,
/// First expression in the tuple is a color, second expression is the stop position (normalized angle 0-1)
stops: Vec<(Expression, Expression)>,
},
@ -390,7 +392,8 @@ macro_rules! visit_impl {
$visitor(b);
}
}
Expression::ConicGradient { stops } => {
Expression::ConicGradient { from_angle, stops } => {
$visitor(from_angle);
for (a, b) in stops {
$visitor(a);
$visitor(b);

View file

@ -233,7 +233,8 @@ pub fn lower_expression(
.map(|(a, b)| (lower_expression(a, ctx), lower_expression(b, ctx)))
.collect::<_>(),
},
tree_Expression::ConicGradient { stops } => llr_Expression::ConicGradient {
tree_Expression::ConicGradient { from_angle, stops } => llr_Expression::ConicGradient {
from_angle: Box::new(lower_expression(from_angle, ctx)),
stops: stops
.iter()
.map(|(a, b)| (lower_expression(a, ctx), lower_expression(b, ctx)))

View file

@ -331,9 +331,10 @@ impl<'a, T> Display for DisplayExpression<'a, T> {
"@radial-gradient(circle, {})",
stops.iter().map(|(e1, e2)| format!("{} {}", e(e1), e(e2))).join(", ")
),
Expression::ConicGradient { stops } => write!(
Expression::ConicGradient { from_angle, stops } => write!(
f,
"@conic-gradient({})",
"@conic-gradient(from {}, {})",
e(from_angle),
stops.iter().map(|(e1, e2)| format!("{} {}", e(e1), e(e2))).join(", ")
),
Expression::EnumerationValue(x) => write!(f, "{x}"),

View file

@ -511,27 +511,28 @@ impl Expression {
enum GradKind {
Linear { angle: Box<Expression> },
Radial,
Conic,
Conic { from_angle: Box<Expression> },
}
let mut subs = node
let all_subs: Vec<_> = node
.children_with_tokens()
.filter(|n| matches!(n.kind(), SyntaxKind::Comma | SyntaxKind::Expression));
.filter(|n| matches!(n.kind(), SyntaxKind::Comma | SyntaxKind::Expression))
.collect();
let grad_token = node.child_token(SyntaxKind::Identifier).unwrap();
let grad_text = grad_token.text();
let grad_kind = if grad_text.starts_with("linear") {
let angle_expr = match subs.next() {
let (grad_kind, stops_start_idx) = if grad_text.starts_with("linear") {
let angle_expr = match all_subs.first() {
Some(e) if e.kind() == SyntaxKind::Expression => {
syntax_nodes::Expression::from(e.into_node().unwrap())
syntax_nodes::Expression::from(e.as_node().unwrap().clone())
}
_ => {
ctx.diag.push_error("Expected angle expression".into(), &node);
return Expression::Invalid;
}
};
if subs.next().is_some_and(|s| s.kind() != SyntaxKind::Comma) {
if !all_subs.get(1).is_some_and(|s| s.kind() == SyntaxKind::Comma) {
ctx.diag.push_error(
"Angle expression must be an angle followed by a comma".into(),
&node,
@ -545,28 +546,65 @@ impl Expression {
ctx.diag,
),
);
GradKind::Linear { angle }
(GradKind::Linear { angle }, 2)
} else if grad_text.starts_with("radial") {
if !matches!(subs.next(), Some(NodeOrToken::Node(n)) if n.text().to_string().trim() == "circle")
{
if !all_subs.first().is_some_and(|n| {
matches!(n, NodeOrToken::Node(node) if node.text().to_string().trim() == "circle")
}) {
ctx.diag.push_error("Expected 'circle': currently, only @radial-gradient(circle, ...) are supported".into(), &node);
return Expression::Invalid;
}
let comma = subs.next();
let comma = all_subs.get(1);
if matches!(&comma, Some(NodeOrToken::Node(n)) if n.text().to_string().trim() == "at") {
ctx.diag.push_error("'at' in @radial-gradient is not yet supported".into(), &comma);
return Expression::Invalid;
}
if comma.as_ref().is_some_and(|s| s.kind() != SyntaxKind::Comma) {
ctx.diag.push_error(
"'circle' must be followed by a comma".into(),
comma.as_ref().map_or(&node, |x| x as &dyn Spanned),
"'at' in @radial-gradient is not yet supported".into(),
comma.unwrap(),
);
return Expression::Invalid;
}
GradKind::Radial
if !comma.is_some_and(|s| s.kind() == SyntaxKind::Comma) {
ctx.diag.push_error(
"'circle' must be followed by a comma".into(),
comma.map_or(&node as &dyn Spanned, |x| x as &dyn Spanned),
);
return Expression::Invalid;
}
(GradKind::Radial, 2)
} else if grad_text.starts_with("conic") {
GradKind::Conic
// Check for optional "from <angle>" syntax
let (from_angle, start_idx) = if all_subs.first().is_some_and(|n| {
matches!(n, NodeOrToken::Node(node) if node.text().to_string().trim() == "from")
}) {
// Parse "from <angle>" syntax
let angle_expr = match all_subs.get(1) {
Some(e) if e.kind() == SyntaxKind::Expression => {
syntax_nodes::Expression::from(e.as_node().unwrap().clone())
}
_ => {
ctx.diag.push_error("Expected angle expression after 'from'".into(), &node);
return Expression::Invalid;
}
};
if !all_subs.get(2).is_some_and(|s| s.kind() == SyntaxKind::Comma) {
ctx.diag.push_error(
"'from <angle>' must be followed by a comma".into(),
&node,
);
return Expression::Invalid;
}
let angle = Box::new(
Expression::from_expression_node(angle_expr.clone(), ctx).maybe_convert_to(
Type::Angle,
&angle_expr,
ctx.diag,
),
);
(angle, 3)
} else {
// Default to 0deg when "from" is omitted
(Box::new(Expression::NumberLiteral(0., Unit::Deg)), 0)
};
(GradKind::Conic { from_angle }, start_idx)
} else {
// Parser should have ensured we have one of the linear, radial or conic gradient
panic!("Not a gradient {grad_text:?}");
@ -579,11 +617,11 @@ impl Expression {
Finished,
}
let mut current_stop = Stop::Empty;
for n in subs {
for n in all_subs.iter().skip(stops_start_idx) {
if n.kind() == SyntaxKind::Comma {
match std::mem::replace(&mut current_stop, Stop::Empty) {
Stop::Empty => {
ctx.diag.push_error("Expected expression".into(), &n);
ctx.diag.push_error("Expected expression".into(), n);
break;
}
Stop::Finished => {}
@ -607,18 +645,18 @@ impl Expression {
};
match std::mem::replace(&mut current_stop, Stop::Finished) {
Stop::Empty => {
current_stop = Stop::Color(e.maybe_convert_to(Type::Color, &n, ctx.diag))
current_stop = Stop::Color(e.maybe_convert_to(Type::Color, n, ctx.diag))
}
Stop::Finished => {
ctx.diag.push_error("Expected comma".into(), &n);
ctx.diag.push_error("Expected comma".into(), n);
break;
}
Stop::Color(col) => {
let stop_type = match &grad_kind {
GradKind::Conic => Type::Angle,
GradKind::Conic { .. } => Type::Angle,
_ => Type::Float32,
};
stops.push((col, e.maybe_convert_to(stop_type, &n, ctx.diag)))
stops.push((col, e.maybe_convert_to(stop_type, n, ctx.diag)))
}
}
}
@ -678,19 +716,13 @@ impl Expression {
match grad_kind {
GradKind::Linear { angle } => Expression::LinearGradient { angle, stops },
GradKind::Radial => Expression::RadialGradient { stops },
GradKind::Conic => {
// For conic gradients, we need to:
// 1. Ensure angle expressions are converted to Type::Angle
// 2. Normalize to 0-1 range for internal representation
GradKind::Conic { from_angle } => {
// Normalize angles to 0-1 range by dividing by 360deg
let normalized_stops = stops
.into_iter()
.map(|(color, angle_expr)| {
// First ensure the angle expression is properly typed as Angle
let angle_typed =
angle_expr.maybe_convert_to(Type::Angle, &node, &mut ctx.diag);
// Convert angle to 0-1 range by dividing by 360deg
// This ensures all angle units (deg, rad, turn) are normalized
let normalized_pos = Expression::BinaryExpression {
lhs: Box::new(angle_typed),
rhs: Box::new(Expression::NumberLiteral(360., Unit::Deg)),
@ -699,7 +731,17 @@ impl Expression {
(color, normalized_pos)
})
.collect();
Expression::ConicGradient { stops: normalized_stops }
let normalized_from_angle = Box::new(Expression::BinaryExpression {
lhs: from_angle,
rhs: Box::new(Expression::NumberLiteral(360., Unit::Deg)),
op: '/',
});
Expression::ConicGradient {
from_angle: normalized_from_angle,
stops: normalized_stops,
}
}
}
}

View file

@ -91,9 +91,12 @@ fn without_side_effects(expression: &Expression) -> bool {
Expression::RadialGradient { stops } => stops
.iter()
.all(|(start, end)| without_side_effects(start) && without_side_effects(end)),
Expression::ConicGradient { stops } => stops
.iter()
.all(|(start, end)| without_side_effects(start) && without_side_effects(end)),
Expression::ConicGradient { from_angle, stops } => {
without_side_effects(from_angle)
&& stops
.iter()
.all(|(start, end)| without_side_effects(start) && without_side_effects(end))
}
Expression::EnumerationValue(_) => true,
// A return statement is never without side effects, as an important "side effect" is that
// the current function stops at this point.

View file

@ -0,0 +1,28 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
export component X {
// Valid conic gradients
property<brush> g1: @conic-gradient();
property<brush> g2: @conic-gradient(blue 0deg, red 180deg);
property<brush> g3: @conic-gradient(blue 45deg, red 180deg, green 270deg);
property<brush> g4: @conic-gradient(from 90deg, blue 0deg, red 180deg);
property<brush> g5: @conic-gradient(from 90deg + 0.5turn, true ? blue : red 45deg, red 180deg);
// Angles outside 0-360deg range using expressions
property<brush> g6: @conic-gradient(blue 0deg - 45deg, red 180deg);
property<brush> g7: @conic-gradient(blue 450deg, red 720deg);
property<brush> g8: @conic-gradient(from 0deg - 90deg, blue 0deg, red 180deg);
property<brush> g9: @conic-gradient(from 450deg, blue 0deg, red 180deg);
property<angle> neg_angle: -45deg;
property<brush> g10: @conic-gradient(blue neg_angle, red 180deg);
property<brush> g11: @conic-gradient(from neg_angle, blue 0deg, red 180deg);
// Error cases for 'from' syntax
property<brush> g12: @conic-gradient(from 2, blue 45deg, red 180deg);
// ^error{Cannot convert float to angle. Use an unit, or multiply by 1deg to convert explicitly}
property<brush> g13: @conic-gradient(from,);
// ^error{Expected angle expression after 'from'}
property<brush> g14: @conic-gradient(from 90deg blue 0deg, red 180deg);
// ^error{'from <angle>' must be followed by a comma}
}

View file

@ -833,7 +833,8 @@ impl Snapshotter {
.map(|(e1, e2)| (self.snapshot_expression(e1), self.snapshot_expression(e2)))
.collect(),
},
Expression::ConicGradient { stops } => Expression::ConicGradient {
Expression::ConicGradient { from_angle, stops } => Expression::ConicGradient {
from_angle: Box::new(self.snapshot_expression(from_angle)),
stops: stops
.iter()
.map(|(e1, e2)| (self.snapshot_expression(e1), self.snapshot_expression(e2)))

View file

@ -12,6 +12,8 @@ use euclid::default::{Point2D, Size2D};
#[cfg(not(feature = "std"))]
use num_traits::float::Float;
#[cfg(not(feature = "std"))]
use num_traits::Euclid;
/// A brush is a data structure that is used to describe how
/// a shape, such as a rectangle, path or even text, shall be filled.
@ -112,12 +114,13 @@ impl Brush {
GradientStop { color: s.color.brighter(factor), position: s.position }
})))
}
Brush::ConicGradient(g) => {
Brush::ConicGradient(ConicGradientBrush::new(g.stops().map(|s| GradientStop {
Brush::ConicGradient(g) => Brush::ConicGradient(ConicGradientBrush::new(
g.from_angle,
g.stops().map(|s| GradientStop {
color: s.color.brighter(factor),
position: s.position,
})))
}
}),
)),
}
}
@ -138,6 +141,7 @@ impl Brush {
.map(|s| GradientStop { color: s.color.darker(factor), position: s.position }),
)),
Brush::ConicGradient(g) => Brush::ConicGradient(ConicGradientBrush::new(
g.from_angle,
g.stops()
.map(|s| GradientStop { color: s.color.darker(factor), position: s.position }),
)),
@ -165,12 +169,13 @@ impl Brush {
GradientStop { color: s.color.transparentize(amount), position: s.position }
})))
}
Brush::ConicGradient(g) => {
Brush::ConicGradient(ConicGradientBrush::new(g.stops().map(|s| GradientStop {
Brush::ConicGradient(g) => Brush::ConicGradient(ConicGradientBrush::new(
g.from_angle,
g.stops().map(|s| GradientStop {
color: s.color.transparentize(amount),
position: s.position,
})))
}
}),
)),
}
}
@ -192,12 +197,13 @@ impl Brush {
GradientStop { color: s.color.with_alpha(alpha), position: s.position }
})))
}
Brush::ConicGradient(g) => {
Brush::ConicGradient(ConicGradientBrush::new(g.stops().map(|s| GradientStop {
Brush::ConicGradient(g) => Brush::ConicGradient(ConicGradientBrush::new(
g.from_angle,
g.stops().map(|s| GradientStop {
color: s.color.with_alpha(alpha),
position: s.position,
})))
}
}),
)),
}
}
}
@ -254,20 +260,163 @@ impl RadialGradientBrush {
/// The ConicGradientBrush describes a way of filling a shape with a gradient
/// that rotates around a center point
#[derive(Clone, PartialEq, Debug)]
#[repr(transparent)]
pub struct ConicGradientBrush(SharedVector<GradientStop>);
#[repr(C)]
pub struct ConicGradientBrush {
/// The starting angle (rotation) of the gradient in normalized form (0.0 = 0°, 1.0 = 360°)
pub from_angle: f32,
/// The color stops of the gradient
pub stops: SharedVector<GradientStop>,
}
impl ConicGradientBrush {
/// Creates a new conic gradient with the provided color stops.
/// The stops should have angle positions in the range 0.0 to 1.0,
/// where 0.0 is 0 degrees (north) and 1.0 is 360 degrees.
pub fn new(stops: impl IntoIterator<Item = GradientStop>) -> Self {
Self(stops.into_iter().collect())
/// Creates a new conic gradient with the provided starting angle and color stops.
///
/// The `from_angle` parameter is in normalized form (0.0 = 0°, 1.0 = 360°), corresponding
/// to CSS's `from <angle>` syntax. It rotates the entire gradient clockwise.
pub fn new(from_angle: f32, stops: impl IntoIterator<Item = GradientStop>) -> Self {
let mut stops: alloc::vec::Vec<_> = stops.into_iter().collect();
stops.sort_by(|a, b| {
a.position.partial_cmp(&b.position).unwrap_or(core::cmp::Ordering::Equal)
});
// Add interpolated boundary stop at 0.0 if needed
let has_stop_at_0 = stops.iter().any(|s| s.position.abs() < f32::EPSILON);
if !has_stop_at_0 {
// Find stops closest to boundaries for interpolation
// For 0.0: find the stop just below 0 and just at/above 0
let stop_below_0 = stops.iter().filter(|s| s.position < 0.0).last(); // closest to 0 from below
let stop_above_0 = stops.iter().filter(|s| s.position >= 0.0).next(); // closest to 0 from above
if let (Some(below), Some(above)) = (stop_below_0, stop_above_0) {
// Interpolate between the stop below 0 and the stop above 0
// Example: -10deg and 10deg → interpolate at 0deg
let t = (0.0 - below.position) / (above.position - below.position);
let color_at_0 = Self::interpolate_color(below.color, above.color, t);
stops.insert(0, GradientStop { position: 0.0, color: color_at_0 });
} else if let Some(above) = stop_above_0 {
// Only stops above 0, use the first stop's color
stops.insert(0, GradientStop { position: 0.0, color: above.color });
}
}
// Add interpolated boundary stop at 1.0 if needed
let has_stop_at_1 = stops.iter().any(|s| (s.position - 1.0).abs() < f32::EPSILON);
if !has_stop_at_1 {
// For 1.0: find the stop just at/below 1 and just above 1
let stop_below_1 = stops.iter().filter(|s| s.position <= 1.0).last(); // closest to 1 from below
let stop_above_1 = stops.iter().filter(|s| s.position > 1.0).next(); // closest to 1 from above
if let (Some(below), Some(above)) = (stop_below_1, stop_above_1) {
// Interpolate between the stop below 1 and the stop above 1
// Example: 350deg and 370deg → interpolate at 360deg
let t = (1.0 - below.position) / (above.position - below.position);
let color_at_1 = Self::interpolate_color(below.color, above.color, t);
stops.push(GradientStop { position: 1.0, color: color_at_1 });
} else if let Some(below) = stop_below_1 {
// Only stops below 1, use the last stop's color
stops.push(GradientStop { position: 1.0, color: below.color });
}
}
// Drop stops under 0deg and over 360deg
stops = stops.into_iter().filter(|s| 0.0 <= s.position && s.position <= 1.0).collect();
// Adjust first stop (at 0.0) to avoid duplicate with stop at 1.0
if let Some(first) = stops.first_mut() {
if first.position.abs() < f32::EPSILON {
first.position = f32::EPSILON;
}
}
Self { from_angle, stops: SharedVector::from_iter(stops.into_iter()) }
}
/// Returns the color stops of the conic gradient.
pub fn stops(&self) -> impl Iterator<Item = &GradientStop> {
self.0.iter()
self.stops.iter()
}
/// Returns the color stops with the `from_angle` rotation applied.
///
/// This method returns a new vector of stops where:
/// 1. Stops outside [0.0, 1.0] range are removed
/// 2. Boundary stops at 0.0 and 1.0 are added if missing (with interpolated colors)
/// 3. Each stop's position is adjusted by adding `from_angle` and wrapping to [0.0, 1.0]
/// 4. The stops are sorted by their rotated positions
///
/// This is useful when you need to work with the actual visual positions of the stops
/// after the gradient has been rotated.
pub fn rotated_stops(&self) -> alloc::vec::Vec<GradientStop> {
let mut stops: alloc::vec::Vec<_> = self.stops.iter().copied().collect();
let from_angle = self.from_angle - self.from_angle.floor();
if from_angle.abs() > f32::EPSILON {
// Step 2: Apply rotation by adding stops and wrapping to [0, 1) range
stops = stops
.iter()
.map(|stop| {
#[cfg(feature = "std")]
let rotated_position = (stop.position + from_angle).rem_euclid(1.0);
#[cfg(not(feature = "std"))]
let rotated_position = (stop.position + from_angle).rem_euclid(&1.0);
GradientStop { position: rotated_position, color: stop.color }
})
.collect();
// Step 3: Separate duplicate positions with different colors to avoid flickering
for i in 0..stops.len() {
let j = (i + 1) % stops.len();
if (stops[i].position - stops[j].position).abs() < f32::EPSILON
&& stops[i].color != stops[j].color
{
stops[i].position = (stops[i].position - f32::EPSILON).max(0.0);
stops[j].position = (stops[j].position + f32::EPSILON).min(1.0);
break;
}
}
// Step 4: Sort by rotated position
stops.sort_by(|a, b| {
a.position.partial_cmp(&b.position).unwrap_or(core::cmp::Ordering::Equal)
});
// Step 5: Add boundary stops at 0.0 and 1.0 if missing
let has_stop_at_0 = stops.iter().any(|s| s.position.abs() < f32::EPSILON);
if !has_stop_at_0 {
// Find the color at position 0.0 by interpolating from the last and first stops
if let (Some(last), Some(first)) = (stops.last(), stops.first()) {
let gap = 1.0 - last.position + first.position;
let color_at_0 = if gap > f32::EPSILON {
let t = (1.0 - last.position) / gap;
Self::interpolate_color(last.color, first.color, t)
} else {
last.color
};
stops.insert(0, GradientStop { position: 0.0, color: color_at_0 });
}
}
let has_stop_at_1 = stops.iter().any(|s| (s.position - 1.0).abs() < f32::EPSILON);
if !has_stop_at_1 {
// Add stop at 1.0 with same color as stop at 0.0
if let Some(first) = stops.first() {
stops.push(GradientStop { position: 1.0, color: first.color });
}
}
}
stops
}
/// Helper: Linearly interpolate between two colors
fn interpolate_color(c1: Color, c2: Color, t: f32) -> Color {
let argb1 = c1.to_argb_u8();
let argb2 = c2.to_argb_u8();
Color::from_argb_u8(
((1.0 - t) * argb1.alpha as f32 + t * argb2.alpha as f32) as u8,
((1.0 - t) * argb1.red as f32 + t * argb2.red as f32) as u8,
((1.0 - t) * argb1.green as f32 + t * argb2.green as f32) as u8,
((1.0 - t) * argb1.blue as f32 + t * argb2.blue as f32) as u8,
)
}
}
@ -382,7 +531,7 @@ impl InterpolatedPropertyValue for Brush {
}
(Brush::SolidColor(col), Brush::ConicGradient(grad)) => {
let mut new_grad = grad.clone();
for x in new_grad.0.make_mut_slice().iter_mut() {
for x in new_grad.stops.make_mut_slice().iter_mut() {
x.color = col.interpolate(&x.color, t);
}
Brush::ConicGradient(new_grad)
@ -391,11 +540,12 @@ impl InterpolatedPropertyValue for Brush {
Self::interpolate(b, a, 1. - t)
}
(Brush::ConicGradient(lhs), Brush::ConicGradient(rhs)) => {
if lhs.0.len() < rhs.0.len() {
if lhs.stops.len() < rhs.stops.len() {
Self::interpolate(target_value, self, 1. - t)
} else {
let mut new_grad = lhs.clone();
let mut iter = new_grad.0.make_mut_slice().iter_mut();
new_grad.from_angle = lhs.from_angle.interpolate(&rhs.from_angle, t);
let mut iter = new_grad.stops.make_mut_slice().iter_mut();
for s2 in rhs.stops() {
let s1 = iter.next().unwrap();
s1.color = s1.color.interpolate(&s2.color, t);

View file

@ -1427,7 +1427,8 @@ fn process_rectangle_impl(
} else if let Brush::ConicGradient(g) = &args.background {
let conic_grad = ConicGradientCommand {
stops: g
.stops()
.rotated_stops()
.iter()
.map(|s| {
let mut stop = *s;
stop.color = alpha_color(stop.color, args.alpha);

View file

@ -371,12 +371,16 @@ pub fn eval_expression(expression: &Expression, local_context: &mut EvalLocalCon
GradientStop{ color, position }
}))))
}
Expression::ConicGradient{stops} => {
Value::Brush(Brush::ConicGradient(ConicGradientBrush::new(stops.iter().map(|(color, stop)| {
let color = eval_expression(color, local_context).try_into().unwrap();
let position = eval_expression(stop, local_context).try_into().unwrap();
GradientStop{ color, position }
}))))
Expression::ConicGradient{ from_angle, stops } => {
let from_angle: f32 = eval_expression(from_angle, local_context).try_into().unwrap();
Value::Brush(Brush::ConicGradient(ConicGradientBrush::new(
from_angle,
stops.iter().map(|(color, stop)| {
let color = eval_expression(color, local_context).try_into().unwrap();
let position = eval_expression(stop, local_context).try_into().unwrap();
GradientStop{ color, position }
})
)))
}
Expression::EnumerationValue(value) => {
Value::EnumerationValue(value.enumeration.name.to_string(), value.to_string())

View file

@ -1501,18 +1501,12 @@ impl<'a, R: femtovg::Renderer + TextureImporter> GLItemRenderer<'a, R> {
let path_width = path_bounds.width();
let path_height = path_bounds.height();
let mut stops: Vec<_> = gradient
.stops()
let stops: Vec<_> = gradient
.rotated_stops()
.iter()
.map(|stop| (stop.position, to_femtovg_color(&stop.color)))
.collect();
// Add an extra stop at 1.0 with the same color as the last stop
if let Some(last_stop) = stops.last().cloned() {
if last_stop.0 != 1.0 {
stops.push((1.0, last_stop.1));
}
}
femtovg::Paint::conic_gradient_stops(path_width / 2., path_height / 2., stops)
}
_ => return None,

View file

@ -142,7 +142,7 @@ impl<'a> SkiaItemRenderer<'a> {
}
Brush::ConicGradient(g) => {
let (colors, pos): (Vec<_>, Vec<_>) =
g.stops().map(|s| (to_skia_color(&s.color), s.position)).unzip();
g.rotated_stops().iter().map(|s| (to_skia_color(&s.color), s.position)).unzip();
paint.set_dither(true);

View file

@ -33,6 +33,14 @@ export component TestCase inherits Window {
// Edge case: invisible stop at start
Rectangle { background: @conic-gradient(transparent 0deg, red 3.6deg, white 180deg, transparent 360deg); }
}
Row {
// Test 'from <angle>' syntax: default (from 0deg)
Rectangle { background: @conic-gradient(red 0deg, blue 180deg, red 360deg); }
// Rotated by 90deg
Rectangle { background: @conic-gradient(from 90deg, red 0deg, blue 180deg, red 360deg); }
// Rotated by 180deg
Rectangle { background: @conic-gradient(from 180deg, red 0deg, blue 180deg, red 360deg); }
}
}
init => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Before After
Before After

View file

@ -241,15 +241,18 @@ fn eval_expression(
},
)),
)),
Expression::ConicGradient { stops } => Value::Brush(slint::Brush::ConicGradient(
i_slint_core::graphics::ConicGradientBrush::new(stops.iter().map(|(color, stop)| {
let color =
eval_expression(color, local_context, None).try_into().unwrap_or_default();
let position =
eval_expression(stop, local_context, None).try_into().unwrap_or_default();
i_slint_core::graphics::GradientStop { color, position }
})),
)),
Expression::ConicGradient { from_angle, stops } => Value::Brush(
slint::Brush::ConicGradient(i_slint_core::graphics::ConicGradientBrush::new(
eval_expression(from_angle, local_context, None).try_into().unwrap_or_default(),
stops.iter().map(|(color, stop)| {
let color =
eval_expression(color, local_context, None).try_into().unwrap_or_default();
let position =
eval_expression(stop, local_context, None).try_into().unwrap_or_default();
i_slint_core::graphics::GradientStop { color, position }
}),
)),
),
Expression::EnumerationValue(value) => {
Value::EnumerationValue(value.enumeration.name.to_string(), value.to_string())
}

View file

@ -107,8 +107,16 @@ fn as_slint_brush(
}
ui::BrushKind::Conic => {
let stops = sorted_gradient_stops(stops);
let angle = angle.rem_euclid(360.0);
let prefix = if angle.abs() > f32::EPSILON {
slint::format!("from {}deg, ", angle)
} else {
slint::SharedString::new()
};
slint::format!(
"@conic-gradient({})",
"@conic-gradient({}{})",
prefix,
stops
.iter()
.map(|s| format!("{} {}deg", color_to_string(s.color), s.position * 360.0))
@ -147,7 +155,7 @@ pub fn create_brush(
i_slint_core::graphics::RadialGradientBrush::new_circle(stops.drain(..)),
),
ui::BrushKind::Conic => slint::Brush::ConicGradient(
i_slint_core::graphics::ConicGradientBrush::new(stops.drain(..)),
i_slint_core::graphics::ConicGradientBrush::new(angle / 360.0, stops.drain(..)),
),
}
}