diff --git a/lib/src/colors.dart b/lib/src/colors.dart index 5bc20bba..8908a84e 100644 --- a/lib/src/colors.dart +++ b/lib/src/colors.dart @@ -109,3 +109,102 @@ class YaruColors { /// Xubuntu Blue static const Color xubuntuBlue = Color(0xFF0044AA); } + +/// Set of useful methods when working with [Color] +extension YaruColorExtension on Color { + /// Scale color attributes relatively to current ones. + /// [alpha], [hue], [saturation] and [lightness] values must be clamped between -1.0 and 1.0 + Color scale({ + double alpha = 0.0, + double hue = 0.0, + double saturation = 0.0, + double lightness = 0.0, + }) { + assert(alpha >= -1.0 && alpha <= 1.0); + assert(hue >= -1.0 && hue <= 1.0); + assert(saturation >= -1.0 && saturation <= 1.0); + assert(lightness >= -1.0 && lightness <= 1.0); + + final hslColor = _getPatchedHslColor(); + + double scale(double value, double amount, [double upperLimit = 1.0]) { + var result = value; + + if (amount > 0) { + result = value + (upperLimit - value) * amount; + } else if (amount < 0) { + result = value + value * amount; + } + + return result.clamp(0.0, upperLimit); + } + + return hslColor + .withAlpha(scale(opacity, alpha)) + .withHue(scale(hslColor.hue, hue, 360.0)) + .withSaturation(scale(hslColor.saturation, saturation)) + .withLightness(scale(hslColor.lightness, lightness)) + .toColor(); + } + + /// Adjust color attributes by the given values. + /// [alpha], [saturation] and [lightness] values must be clamped between -1.0 and 1.0 + /// [hue] value must be clamped between -360.0 and 360.0 + Color adjust({ + double alpha = 0.0, + double hue = 0.0, + double saturation = 0.0, + double lightness = 0.0, + }) { + assert(alpha >= -1.0 && alpha <= 1.0); + assert(hue >= -360.0 && hue <= 360.0); + assert(saturation >= -1.0 && saturation <= 1.0); + assert(lightness >= -1.0 && lightness <= 1.0); + + final hslColor = _getPatchedHslColor(); + + double adjust(double value, double amount, [double upperLimit = 1.0]) { + return (value + amount).clamp(0.0, upperLimit); + } + + return hslColor + .withAlpha(adjust(hslColor.alpha, alpha)) + .withHue(adjust(hslColor.hue, hue, 360.0)) + .withSaturation(adjust(hslColor.saturation, saturation)) + .withLightness(adjust(hslColor.lightness, lightness)) + .toColor(); + } + + /// Return a copy of this color with attributes replaced by given values. + /// [alpha], [saturation] and [lightness] values must be clamped between 0.0 and 1.0 + /// [hue] value must be clamped between 0.0 and 360.0 + Color copyWith({ + double? alpha, + double? hue, + double? saturation, + double? lightness, + }) { + assert(alpha == null || (alpha >= 0.0 && alpha <= 1.0)); + assert(hue == null || (hue >= 0.0 && hue <= 360.0)); + assert(saturation == null || (saturation >= 0.0 && saturation <= 1.0)); + assert(lightness == null || (lightness >= 0.0 && lightness <= 1.0)); + + final hslColor = _getPatchedHslColor(); + + return hslColor + .withAlpha(alpha ?? hslColor.alpha) + .withHue(hue ?? hslColor.hue) + .withSaturation(saturation ?? hslColor.saturation) + .withLightness(lightness ?? hslColor.lightness) + .toColor(); + } + + HSLColor _getPatchedHslColor() { + final hslColor = HSLColor.fromColor(this); + + // A pure dark color have saturation level at 1.0, which results in red when lighten it. + // We reset this value to 0.0, so the result is desaturated as expected: + return hslColor + .withSaturation(hslColor.lightness == 0.0 ? 0.0 : hslColor.saturation); + } +} diff --git a/test/yaru_color_extension_test.dart b/test/yaru_color_extension_test.dart new file mode 100644 index 00000000..e3277eff --- /dev/null +++ b/test/yaru_color_extension_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/src/colors.dart'; + +final Matcher throwsAssertionError = throwsA(isA()); +final Color midColor = const HSLColor.fromAHSL(.5, 180, .5, .5).toColor(); + +void main() { + group('Color.scale() test -', () { + test('With out of range amount', () { + expect(() => midColor.scale(alpha: -1.1), throwsAssertionError); + expect(() => midColor.scale(alpha: 1.1), throwsAssertionError); + expect(() => midColor.scale(hue: -1.1), throwsAssertionError); + expect(() => midColor.scale(hue: 1.1), throwsAssertionError); + expect(() => midColor.scale(saturation: -1.1), throwsAssertionError); + expect(() => midColor.scale(saturation: 1.1), throwsAssertionError); + expect(() => midColor.scale(lightness: -1.1), throwsAssertionError); + expect(() => midColor.scale(lightness: 1.1), throwsAssertionError); + }); + test('With clamped amount', () { + expect( + midColor.scale(alpha: -1.0), + const Color(0x0040bfbf), + ); + expect( + midColor.scale(alpha: 1.0), + const Color(0xff40bfbf), + ); + expect( + midColor.scale(hue: -1.0), + const Color(0x80bf4040), + ); + expect( + midColor.scale(hue: 1.0), + const Color(0x80bf4040), + ); + expect( + midColor.scale(saturation: -1.0), + const Color(0x80808080), + ); + expect( + midColor.scale(saturation: 1.0), + const Color(0x8000ffff), + ); + expect( + midColor.scale(lightness: -1.0), + const Color(0x80000000), + ); + expect( + midColor.scale(lightness: 1.0), + const Color(0x80ffffff), + ); + }); + test('With medium amount', () { + expect( + midColor.scale(alpha: -0.5), + const Color(0x4040bfbf), + ); + expect( + midColor.scale(alpha: 0.5), + const Color(0xc040bfbf), + ); + expect( + midColor.scale(hue: -0.5), + const Color(0x8080bf40), + ); + expect( + midColor.scale(hue: 0.5), + const Color(0x808040bf), + ); + expect( + midColor.scale(saturation: -0.5), + const Color(0x80609f9f), + ); + expect( + midColor.scale(saturation: 0.5), + const Color(0x8020dfdf), + ); + expect( + midColor.scale(lightness: -0.5), + const Color(0x80206060), + ); + expect( + midColor.scale(lightness: 0.5), + const Color(0x80a0dfdf), + ); + }); + }); + + group('Color.adjust() test -', () { + test('With out of range amount', () { + expect(() => midColor.adjust(alpha: -1.1), throwsAssertionError); + expect(() => midColor.adjust(alpha: 1.1), throwsAssertionError); + expect(() => midColor.adjust(hue: -360.1), throwsAssertionError); + expect(() => midColor.adjust(hue: 360.1), throwsAssertionError); + expect(() => midColor.adjust(saturation: -1.1), throwsAssertionError); + expect(() => midColor.adjust(saturation: 1.1), throwsAssertionError); + expect(() => midColor.adjust(lightness: -1.1), throwsAssertionError); + expect(() => midColor.adjust(lightness: 1.1), throwsAssertionError); + }); + test('With clamped amount', () { + expect( + midColor.adjust(alpha: -1.0), + const Color(0x0040bfbf), + ); + expect( + midColor.adjust(alpha: 1.0), + const Color(0xff40bfbf), + ); + expect( + midColor.adjust(hue: -180), + const Color(0x80bf4040), + ); + expect( + midColor.adjust(hue: 180), + const Color(0x80bf4040), + ); + expect( + midColor.adjust(saturation: -1.0), + const Color(0x80808080), + ); + expect( + midColor.adjust(saturation: 1.0), + const Color(0x8000ffff), + ); + expect( + midColor.adjust(lightness: -1.0), + const Color(0x80000000), + ); + expect( + midColor.adjust(lightness: 1.0), + const Color(0x80ffffff), + ); + }); + test('With medium amount', () { + expect( + midColor.adjust(alpha: -0.25), + const Color(0x4040bfbf), + ); + expect( + midColor.adjust(alpha: 0.25), + const Color(0xc040bfbf), + ); + expect( + midColor.adjust(hue: -90), + const Color(0x8080bf40), + ); + expect( + midColor.adjust(hue: 90), + const Color(0x808040bf), + ); + expect( + midColor.adjust(saturation: -0.25), + const Color(0x80609f9f), + ); + expect( + midColor.adjust(saturation: 0.25), + const Color(0x8020dfdf), + ); + expect( + midColor.adjust(lightness: -0.25), + const Color(0x80206060), + ); + expect( + midColor.adjust(lightness: 0.25), + const Color(0x80a0dfdf), + ); + }); + }); + + group('Color.copyWith() test -', () { + test('With out of range amount', () { + expect(() => midColor.copyWith(alpha: -1.1), throwsAssertionError); + expect(() => midColor.copyWith(alpha: 1.1), throwsAssertionError); + expect(() => midColor.copyWith(hue: -360.1), throwsAssertionError); + expect(() => midColor.copyWith(hue: 360.1), throwsAssertionError); + expect(() => midColor.copyWith(saturation: -1.1), throwsAssertionError); + expect(() => midColor.copyWith(saturation: 1.1), throwsAssertionError); + expect(() => midColor.copyWith(lightness: -1.1), throwsAssertionError); + expect(() => midColor.copyWith(lightness: 1.1), throwsAssertionError); + }); + test('With various amount', () { + expect( + midColor.copyWith(alpha: 0.25), + const Color(0x4040bfbf), + ); + expect( + midColor.copyWith(hue: 90), + const Color(0x8080bf40), + ); + expect( + midColor.copyWith(saturation: 0.75), + const Color(0x8020dfdf), + ); + expect( + midColor.copyWith(lightness: 0.75), + const Color(0x80a0dfdf), + ); + }); + }); +}