Skip to content

Commit 62e4fa0

Browse files
authored
Merge pull request #2125 from sass/to-space
[Color 4] Update to support the latest specs
2 parents a03bbe0 + 602c60d commit 62e4fa0

27 files changed

+660
-443
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
is now interpreted as a percentage, instead of ignoring the unit. For example,
66
`color.change(red, $alpha: 50%)` now returns `rgb(255 0 0 / 0.5)`.
77

8+
* **Potentially breaking compatibility fix**: Passing large positive or negative
9+
values to `color.adjust()` can now cause a color's channels to go outside that
10+
color's gamut. In most cases this will currently be clipped by the browser and
11+
end up showing the same color as before, but once browsers implement gamut
12+
mapping it may produce a different result.
13+
814
* Add support for CSS Color Level 4 [color spaces]. Each color value now tracks
915
its color space along with the values of each channel in that color space.
1016
There are two general principles to keep in mind when dealing with new color

lib/src/functions/color.dart

Lines changed: 121 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// https://opensource.org/licenses/MIT.
44

55
import 'dart:collection';
6+
import 'dart:math' as math;
67

78
import 'package:collection/collection.dart';
89

@@ -423,11 +424,21 @@ final module = BuiltInModule("color", functions: <Callable>[
423424
(arguments) => SassString(arguments.first.assertColor("color").space.name,
424425
quotes: false)),
425426

426-
_function(
427-
"to-space",
428-
r"$color, $space",
429-
(arguments) =>
430-
_colorInSpace(arguments[0], arguments[1].assertString("space"))),
427+
_function("to-space", r"$color, $space", (arguments) {
428+
var converted = _colorInSpace(arguments[0], arguments[1]);
429+
// `color.to-space()` never returns missing channels for legacy color
430+
// spaces because they're less compatible and users are probably using a
431+
// legacy space because they want a highly compatible color.
432+
return converted.isLegacy &&
433+
(converted.isChannel0Missing ||
434+
converted.isChannel1Missing ||
435+
converted.isChannel2Missing ||
436+
converted.isAlphaMissing) &&
437+
converted.space != (arguments[0] as SassColor).space
438+
? SassColor.forSpaceInternal(converted.space, converted.channel0,
439+
converted.channel1, converted.channel2, converted.alpha)
440+
: converted;
441+
}),
431442

432443
_function("is-legacy", r"$color",
433444
(arguments) => SassBoolean(arguments[0].assertColor("color").isLegacy)),
@@ -459,7 +470,7 @@ final module = BuiltInModule("color", functions: <Callable>[
459470
? ColorSpace.srgb
460471
: space)
461472
.toGamut()
462-
.toSpace(space);
473+
.toSpace(color.space);
463474
}),
464475

465476
_function("channel", r"$color, $channel, $space: null", (arguments) {
@@ -706,7 +717,7 @@ SassColor _updateComponents(List<Value> arguments,
706717
: _colorInSpace(originalColor, spaceKeyword ?? sassNull);
707718

708719
var oldChannels = color.channels;
709-
var channelArgs = List<SassNumber?>.filled(oldChannels.length, null);
720+
var channelArgs = List<Value?>.filled(oldChannels.length, null);
710721
var channelInfo = color.space.channels;
711722
for (var (name, value) in keywords.pairs) {
712723
var channelIndex = channelInfo.indexWhere((info) => name == info.name);
@@ -716,50 +727,75 @@ SassColor _updateComponents(List<Value> arguments,
716727
name);
717728
}
718729

719-
channelArgs[channelIndex] = value.assertNumber(name);
730+
channelArgs[channelIndex] = value;
720731
}
721732

722-
var result = change
723-
? _changeColor(color, channelArgs, alphaArg)
724-
: scale
725-
? _scaleColor(color, channelArgs, alphaArg)
726-
: _adjustColor(color, channelArgs, alphaArg);
733+
SassColor result;
734+
if (change) {
735+
result = _changeColor(color, channelArgs, alphaArg);
736+
} else {
737+
var channelNumbers = [
738+
for (var i = 0; i < channelInfo.length; i++)
739+
channelArgs[i]?.assertNumber(channelInfo[i].name)
740+
];
741+
result = scale
742+
? _scaleColor(color, channelNumbers, alphaArg)
743+
: _adjustColor(color, channelNumbers, alphaArg);
744+
}
727745

728746
return result.toSpace(originalColor.space);
729747
}
730748

731749
/// Returns a copy of [color] with its channel values replaced by those in
732750
/// [channelArgs] and [alphaArg], if specified.
733751
SassColor _changeColor(
734-
SassColor color, List<SassNumber?> channelArgs, SassNumber? alphaArg) {
735-
var latterUnits =
736-
color.space == ColorSpace.hsl || color.space == ColorSpace.hwb
737-
? '%'
738-
: null;
739-
return _colorFromChannels(
740-
color.space,
741-
channelArgs[0] ?? SassNumber(color.channel0),
742-
channelArgs[1] ?? SassNumber(color.channel1, latterUnits),
743-
channelArgs[2] ?? SassNumber(color.channel2, latterUnits),
744-
alphaArg.andThen((alphaArg) {
745-
if (!alphaArg.hasUnits) {
746-
return alphaArg.value;
747-
} else if (alphaArg.hasUnit('%')) {
748-
return alphaArg.value / 100;
749-
} else {
750-
warnForDeprecation(
751-
"\$alpha: Passing a unit other than % ($alphaArg) is "
752-
"deprecated.\n"
753-
"\n"
754-
"To preserve current behavior: "
755-
"${alphaArg.unitSuggestion('alpha')}\n"
756-
"\n"
757-
"See https://sass-lang.com/d/function-units",
758-
Deprecation.functionUnits);
759-
return alphaArg.value;
760-
}
761-
}) ??
762-
color.alpha);
752+
SassColor color, List<Value?> channelArgs, SassNumber? alphaArg) =>
753+
_colorFromChannels(
754+
color.space,
755+
_channelForChange(channelArgs[0], color, 0),
756+
_channelForChange(channelArgs[1], color, 1),
757+
_channelForChange(channelArgs[2], color, 2),
758+
alphaArg.andThen((alphaArg) {
759+
if (!alphaArg.hasUnits) {
760+
return alphaArg.value;
761+
} else if (alphaArg.hasUnit('%')) {
762+
return alphaArg.value / 100;
763+
} else {
764+
warnForDeprecation(
765+
"\$alpha: Passing a unit other than % ($alphaArg) is "
766+
"deprecated.\n"
767+
"\n"
768+
"To preserve current behavior: "
769+
"${alphaArg.unitSuggestion('alpha')}\n"
770+
"\n"
771+
"See https://sass-lang.com/d/function-units",
772+
Deprecation.functionUnits);
773+
return alphaArg.value;
774+
}
775+
}) ??
776+
color.alpha,
777+
clamp: false);
778+
779+
/// Returns the value for a single channel in `color.change()`.
780+
///
781+
/// The [channelArg] is the argument passed in by the user, if one exists. If no
782+
/// argument is passed, the channel at [index] in [color] is used instead.
783+
SassNumber? _channelForChange(Value? channelArg, SassColor color, int channel) {
784+
if (channelArg == null) {
785+
return switch (color.channelsOrNull[channel]) {
786+
var value? => SassNumber(
787+
value,
788+
(color.space == ColorSpace.hsl || color.space == ColorSpace.hwb) &&
789+
channel > 0
790+
? '%'
791+
: null),
792+
_ => null
793+
};
794+
}
795+
if (_isNone(channelArg)) return null;
796+
if (channelArg is SassNumber) return channelArg;
797+
throw SassScriptException('$channelArg is not a number or unquoted "none".',
798+
color.space.channels[channel].name);
763799
}
764800

765801
/// Returns a copy of [color] with its channel values scaled by the values in
@@ -852,10 +888,15 @@ double _adjustChannel(ColorSpace space, ColorChannel channel, double oldValue,
852888
adjustmentArg = SassNumber(adjustmentArg.value);
853889
}
854890

855-
var result = oldValue + _channelFromValue(channel, adjustmentArg)!;
856-
return space.isStrictlyBounded && channel is LinearChannel
857-
? fuzzyClamp(result, channel.min, channel.max)
858-
: result;
891+
var result =
892+
oldValue + _channelFromValue(channel, adjustmentArg, clamp: false)!;
893+
return switch (channel) {
894+
LinearChannel(lowerClamped: true, :var min) when result < min =>
895+
oldValue < min ? math.max(oldValue, result) : min,
896+
LinearChannel(upperClamped: true, :var max) when result > max =>
897+
oldValue > max ? math.min(oldValue, result) : max,
898+
_ => result
899+
};
859900
}
860901

861902
/// Given a map of arguments passed to [_updateComponents] for a legacy color,
@@ -1311,24 +1352,28 @@ Value? _parseNumberOrNone(String text) {
13111352

13121353
/// Creates a [SassColor] for the given [space] from the given channel values,
13131354
/// or throws a [SassScriptException] if the channel values are invalid.
1355+
///
1356+
/// If [clamp] is true, this will clamp any clamped channels.
13141357
SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0,
13151358
SassNumber? channel1, SassNumber? channel2, double? alpha,
1316-
{bool fromRgbFunction = false}) {
1359+
{bool clamp = true, bool fromRgbFunction = false}) {
13171360
switch (space) {
13181361
case ColorSpace.hsl:
13191362
if (channel1 != null) _checkPercent(channel1, 'saturation');
13201363
if (channel2 != null) _checkPercent(channel2, 'lightness');
13211364
return SassColor.hsl(
13221365
channel0.andThen((channel0) => _angleValue(channel0, 'hue')),
1323-
channel1?.value.clamp(0, 100).toDouble(),
1324-
channel2?.value.clamp(0, 100).toDouble(),
1366+
_channelFromValue(space.channels[1], _forcePercent(channel1),
1367+
clamp: clamp),
1368+
_channelFromValue(space.channels[2], _forcePercent(channel2),
1369+
clamp: clamp),
13251370
alpha);
13261371

13271372
case ColorSpace.hwb:
13281373
channel1?.assertUnit('%', 'whiteness');
13291374
channel2?.assertUnit('%', 'blackness');
1330-
var whiteness = channel1?.value.clamp(0, 100).toDouble();
1331-
var blackness = channel2?.value.clamp(0, 100).toDouble();
1375+
var whiteness = channel1?.value.toDouble();
1376+
var blackness = channel2?.value.toDouble();
13321377

13331378
if (whiteness != null &&
13341379
blackness != null &&
@@ -1346,44 +1391,48 @@ SassColor _colorFromChannels(ColorSpace space, SassNumber? channel0,
13461391

13471392
case ColorSpace.rgb:
13481393
return SassColor.rgbInternal(
1349-
_channelFromValue(space.channels[0], channel0),
1350-
_channelFromValue(space.channels[1], channel1),
1351-
_channelFromValue(space.channels[2], channel2),
1394+
_channelFromValue(space.channels[0], channel0, clamp: clamp),
1395+
_channelFromValue(space.channels[1], channel1, clamp: clamp),
1396+
_channelFromValue(space.channels[2], channel2, clamp: clamp),
13521397
alpha,
13531398
fromRgbFunction ? ColorFormat.rgbFunction : null);
13541399

1355-
case ColorSpace.lab ||
1356-
ColorSpace.lch ||
1357-
ColorSpace.oklab ||
1358-
ColorSpace.oklch:
1359-
return SassColor.forSpaceInternal(
1360-
space,
1361-
_channelFromValue(space.channels[0], channel0).andThen((lightness) =>
1362-
fuzzyClamp(
1363-
lightness, 0, (space.channels[0] as LinearChannel).max)),
1364-
_channelFromValue(space.channels[1], channel1),
1365-
_channelFromValue(space.channels[2], channel2),
1366-
alpha);
1367-
13681400
default:
13691401
return SassColor.forSpaceInternal(
13701402
space,
1371-
_channelFromValue(space.channels[0], channel0),
1372-
_channelFromValue(space.channels[1], channel1),
1373-
_channelFromValue(space.channels[2], channel2),
1403+
_channelFromValue(space.channels[0], channel0, clamp: clamp),
1404+
_channelFromValue(space.channels[1], channel1, clamp: clamp),
1405+
_channelFromValue(space.channels[2], channel2, clamp: clamp),
13741406
alpha);
13751407
}
13761408
}
13771409

1410+
/// Returns [number] with unit `'%'` regardless of its original unit.
1411+
SassNumber? _forcePercent(SassNumber? number) => switch (number) {
1412+
null => null,
1413+
SassNumber(numeratorUnits: ['%'], denominatorUnits: []) => number,
1414+
_ => SassNumber(number.value, '%')
1415+
};
1416+
13781417
/// Converts a channel value from a [SassNumber] into a [double] according to
13791418
/// [channel].
1380-
double? _channelFromValue(ColorChannel channel, SassNumber? value) =>
1419+
///
1420+
/// If [clamp] is true, this clamps [value] according to [channel]'s clamping
1421+
/// rules.
1422+
double? _channelFromValue(ColorChannel channel, SassNumber? value,
1423+
{bool clamp = true}) =>
13811424
value.andThen((value) => switch (channel) {
13821425
LinearChannel(requiresPercent: true) when !value.hasUnit('%') =>
13831426
throw SassScriptException(
13841427
'Expected $value to have unit "%".', channel.name),
1385-
LinearChannel() =>
1428+
LinearChannel(lowerClamped: false, upperClamped: false) =>
1429+
_percentageOrUnitless(value, channel.max, channel.name),
1430+
LinearChannel() when !clamp =>
13861431
_percentageOrUnitless(value, channel.max, channel.name),
1432+
LinearChannel(:var lowerClamped, :var upperClamped) =>
1433+
_percentageOrUnitless(value, channel.max, channel.name).clamp(
1434+
lowerClamped ? channel.min : double.negativeInfinity,
1435+
upperClamped ? channel.max : double.infinity),
13871436
_ => value.coerceValueToUnit('deg', channel.name) % 360
13881437
});
13891438

lib/src/util/number.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ double fuzzyClamp(double number, double min, double max) {
9393
return number;
9494
}
9595

96+
/// Returns whether [number] is within [min] and [max] inclusive, using fuzzy
97+
/// equality.
98+
bool fuzzyInRange(double number, num min, num max) =>
99+
fuzzyGreaterThanOrEquals(number, min) && fuzzyLessThanOrEquals(number, max);
100+
96101
/// Returns [number] if it's within [min] and [max], or `null` if it's not.
97102
///
98103
/// If [number] is [fuzzyEquals] to [min] or [max], it's clamped to the

0 commit comments

Comments
 (0)