New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement relative and absolute methods for Lighten/Darken, Saturate #217
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, thank you! Good to see that we can most likely get this with the release. Apart from my comments in the code, I have two things that I came to think of:
- We need too handle the case when the current value is beyond the limit it's moving towards. I would suggest adding a
.max(T::zero())
to make it do nothing, but I have no idea what would be the "proper" way to do it. Now it will just move towards the limit value, so lighten may darken a value above the max limit. - I would like to normalize the "amount" and "factor" terms here. Maybe just "amount" is good, unless something else is more correct.
Yes, my thoughts were that this seemed like a nice addition to try getting in for the new release. Good catch on the There's some shuffling in the
I added the clamping to |
This seems like the least weird option for handling "out-of-bounds" values without complicating it. Let's go with it for now. I just know I/we have been using different terminology (sometimes interchangeably) for factors and amounts, so it would be nice to set a proper standard or pattern that makes sense for what they are. I don't have anything against using different names if they match the different semantics, so if "factor" is correct for relative changes, we could use it for the relative methods and use "amount" for the fixed/absolute methods. I mean since they are also part of the documentation. Does that sound good? And I would also like to encourage having a couple of small examples in the documentation to really show how things are intended to work. Something simple like showing that Aside from those details, there seem to be a test that need to be changed. It's probably based on an incorrect assumption, so it can either be changed tor removed and replaced with the above mentioned examples. |
c8b9a02
to
9de3868
Compare
That makes the most sense and clarifies a lot semantically. I've made it so the I've modified the doc tests for The tests for // Shade
(self.value + amount).max(T::zero())
// Saturate
self.saturation * (T::one() + amount).max(T::zero())
let a = Hsv::new(0.0, 0.5, 1.0);
assert_relative_eq!(a.saturate(0.5).saturation, 0.625);
assert_relative_eq!(a.desaturate_fixed(0.5).saturation, 0.25); So the calculation for let amount = (T::max_intensity() - self.value) * factor;
self.saturation * (T::one() + amount).max(T::zero()) and worked out, it becomes 0.5 * (1 + (1.0 - 0.5) * 0.5)
0.5 * (1 + 0.25)
0.625 |
Ohhh, wait, no, that's not what I had in mind. I must have blinked while reading that earlier, sorry. 😬 Or I may need to sleep. An example of what I'm going for is the color module for the SASS CSS pre-processor: https://sass-lang.com/documentation/modules/color. I don't know how familiar you are with it, but it has some functions for manipulating colors, including the ones from these traits. The For the docs, I'm usually not requiring people to write a lot because I know not everyone is too keen on doing it and I don't want to drag things out. And I know there can be a feeling of "being done" once someone has submitted a PR. But if I get to go into full wish list mode, I would say that it's good if both the text and the examples can demonstrate the functions fully. It should be clear from the text, but it should be fine to also only look at the examples. So feel free to make them completely exhaustive if you want to (while not drowning the reader in text 😅). It's going to be helpful as a guide for both using and implementing the traits. But I think that level of complexity (or simplicity) is good for the examples. They could perhaps just be a bit more verbose and explicit with the variables, like |
I wasn't familiar but I understand the intention a lot better now. I re-read the original issue and see where I went off track keeping the old calculation methods. I think this should be closer to the goal. I added documentation and examples for Shade and Saturate which was challenging to phrase. I removed the examples from the main trait description since they seemed redundant with each function getting its own example. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm back with some rest and a fresher mind. The documentation looks great and I think you explained it really well! 😁 There's just a couple of things regarding the algorithm that I think we need to correct. I'll take Hsl and lighten as an example, but it applies everywhere.
First, I think there was a small miscommunication with where I thought the .max(T::zero())
should be necessary. I think it has to be like this to avoid going backwards with a positive factor: self.lighten_fixed((T::max_intensity() - self.lightness).max(T::zero()) * factor)
. I suppose it's still nice to clamp the end result too, where it applies, since unexpected negative numbers has shown to cause chaos in some cases.
But we need to take care of the negative factors too. Now it won't do anything if lightness is 1.0 and a negative factor is passed. Comparing to the SASS counterparts, our lighten
and saturate
are the same as their scale
for specific parameters. We just make them look specific to positive factors, but they still need to act like darken
and desaturate
for negative factors. I hope this makes sense.
And, finally, since we are using an amount in the *_fixed functions that is meant to range from 0.0 to 1.0 (I think this is good, since it makes it more uniform between spaces), we can't rely on it to be possible to reuse *_fixed here. What if max intensity is 100? 😬
I would suggest changing the algorithm to this with all of that in mind:
fn lighten(&self, factor: Self::Scalar) -> Self {
let difference = if factor >= T::zero() {
T::max_intensity() - self.lightness
} else {
self.lightness
};
let change = difference.max(T::zero()) * factor;
Hsl {
hue: self.hue,
saturation: self.saturation,
lightness: (self.lightness + change).max(T::zero()),
standard: PhantomData,
}
}
I think that should give us the expected symmetry and work with the default implementation for darken.
That makes sense in accounting for negative factors, the difference is I've changed the I've been wondering throughout this: should we clamp the components to the |
Great! Good catch with Lab and Lch. I sort of skimmed through them after realizing it had to be made a bit more complicated than it was.
Yes, I think they will be good as they are. 👍 They are just normalized addition and subtraction.
I have actually been wondering the same, so that's why I haven't maybe been so clear about my standpoint. Prepare for a brain dump! I like to keep things open for "out-of-range" values because they could be useful as deltas (like For these operations, I see the output as more of a display value than a delta value, so I think some minimal clamping is fine. Values that correspond to some kind of brightness should probably just not be negative (what does that even represent? 😄), while values above 100% has their uses. Saturation could perhaps be fully clamped in some of the cases, but I think the conservative option is to only keep them positive as well. The relative methods (
I don't think any of them stand out as a clear winner, just that the current option doesn't do anything too weird without trying to be particularly clever. But the last option could be perfectly valid, with the disclaimer that in-range values are assumed and clamping is recommended. I expect the "pickier" users to roll their own algorithms when they need full control and this is more for "casual" users, for lack of better terms. What's your view on it all? Am I lost in my own thoughts? |
The brain dump on this is welcome. There's a lot going on below the surface that isn't obvious at first glance, so it's good to talk about it especially when taking into account the future.
I agree with all of this. I think there's only a problem if there's a disconnect between user expectations and what the library does or expects of colors. That can be solved by the library stating its goals for the operations. If there's enough friction from use cases then the behavior can be improved or new methods added. Unclamped-max values have uses for arithmetic and other cases, the upper clamp can be left to the user instead of adding more machinery and technical debt to churn through at a later date. Unclamped-min values for lightness, value, saturation, chroma, etc. don't make much sense to me.
Agreed, I think for now this is the best default for the casual/average user. More advanced users can roll their own or contribute their methods back to the library if it's clearly better or general purpose enough. I can imagine some type of scaling maximum limits supplied as a parameter or generic parameter, but other problems like how HDR is handled should probably be solved first then solutions for relative operations will follow. When there are so many options on the table and with no clear-cut best, choosing what seems to be the least surprising option and not over-complicating the API surface seems reasonable until proven otherwise. |
I'll try to do it more often. Don't be afraid to remind me in the future!
Absolutely, that's simply a trade-off that need to be communicated in a good place. I suspect it's not so up-front at the moment. We'll have to try and see how it's received.
It's always possible to scale down to a 0.0-1.0 range while working with the color, and that may even be what they do with proper HDR if I'm not mistaken. My impression is that that's part of why there's a need to differ between HDR and SDR values on a type level. But yes, as you say, it's better to keep it simple. That way it will be easier to reason about and compose with other operations. But I think this PR is pretty much done, unless you have anything to add. 🙂 I'm happy with the result and especially to have something more consistent that should be familiar when comparing to other software. Something you could do, by the way, is to run the |
Add `lighten_fixed` and `darken_fixed` methods to the Shade trait. The `_fixed` functions inherit similar behavior to the old `lighten/darken`. The new implementation scales the color toward the maximum or minimum value for lightness or saturation. This should be more intuitive to adjust as a small factor could have drastically different results based on the color space. The same change is made for saturate/desaturate with corresponding `_fixed` methods. Add doc tests showing new behavior of Saturate and Shade Differentiate between the absolute and relative lighten/darken/saturate by using `amount` to refer to the fixed/absolute functions and `factor` when using the relative versions (the new default version is relative). Account for negative factors in the saturate and shade functions.
42a1ce3
to
0156b10
Compare
That should be good from my end now 🙂 |
Oh, wow, it's surely saturated! They both need to be changed a bit, but I'll do in my housekeeping PR instead, while messing around with the other examples. I might edit them a bit more than just restoring them to the old results, so I think it's better to do it all together and look over the rest of them too. Thank you for the help here and for sharing your thoughts! It's always appreciated! bors r+ |
Build succeeded: |
Add
lighten_fixed
anddarken_fixed
methods to the Shade trait. The_fixed
functions inherit similar behavior to the oldlighten/darken
.The new implementation scales the color toward the maximum or minimum
value for lightness or saturation. This should be more intuitive to
adjust as a small factor could have drastically different results based
on the color space. The same change is made for saturate/desaturate with
corresponding
_fixed
methods.Add doc tests showing new behavior of Saturate and Shade
Differentiate between the absolute and relative lighten/darken/saturate
by using
amount
to refer to the fixed/absolute functions andfactor
when using the relative versions (the new default version is relative).
Account for negative factors in the saturate and shade functions.
closes #215