facelessuser / ColorHelper

Sublime plugin that provides helpful color previews and tooltips
https://facelessuser.github.io/ColorHelper/
MIT License
255 stars 30 forks source link

Discussion: Next Iteration of ColorHelper #158

Closed facelessuser closed 3 years ago

facelessuser commented 4 years ago

The next iteration of ColorHelper may change some things. The aim is to make the library more maintainable. And easier to support.

ColorHelper was originally coded up very fast as a proof of concept, but it is also a bit of a mess inside. It also may be able to be improved to more match what people need, then what I thought was "cool" at the time. The plan is slowly evolving, so I'm open to gathering input as I tackle different areas.

I'd love to hear how people would like it to work differently. Not all Ideas may be implementable. I know processing color variables is one that people have wanted for a long time, but without running a separate process that indexes the files (like an LSP server) that may not be possible (or at least easily doable).

facelessuser commented 4 years ago

The new ColorHelper will rely heavily on the new stand-alone dependency. It will greatly reduce a lot of code in ColorHelper. This will make it easier to maintain. In some ways, the next iteration of ColorHelper will be more configurable.

rwols commented 4 years ago

I'd love to hear how people would like it to work differently.

I would like to refactor out the color handling in LSP to a dependency if possible :) Ideally such a dependency would maintain state about a view. What is considered a "region of color" is determined by the client of the dependency. The client of the dependency should be able to update the state with new information. The dependency should not itself inspect the view when it changes. Based on these requirements I would be happy with something like this:

class Color:
    ...

class ColorRegion:
    __slots__ = ("region", "color", "editable")

    def __init__(self, region: sublime.Region, color: Color, editable: bool) -> None:
        self.region = region
        self.color = color
        self.editable = editable

class ColorView:
    def __init__(self, view: sublime.View) -> None:
        ...

    def update_async(self, regions: List[ColorRegion]) -> None:
        ...

... and that's all, really. Do note that allowing updates in the "async" thread of sublime is important for LSP. If this hypothetical ColorView class can stand on its own and doesn't need global information, then it doesn't matter from which thread this update method runs of course.

This would allow ColorHelper to run in Swift files, JSON files, even Wolfram files I'm noticing just now for instance.

rwols commented 4 years ago

Hmm, thinking about this some more I do think a client would need a callback for when the user committed to an edit he/she made to a certain region+color in the view.

facelessuser commented 4 years ago

Can you elaborate?

facelessuser commented 4 years ago

I need didn't realize you had two posts here, let me read this over. I'm on my phone.

facelessuser commented 4 years ago

I can consider this request if possible. We can maybe have a layer above the color library that can do this. I haven't looked into it yet. I've been spending most of my time trying to implement the new color parsing conversion library. How that can maybe get abstracted for custom color syntaxes and such.

Currently, all color spaces can be handled via the generic format defined in CSS color 5 spec: color(<colorspace> <coord 1> <coord 2> <coord 3> / <alpha>).

I'm not going to be strict with space-delimited format or comma-delimited. I think I'm just going to be loose on that. Currently, I won't process one if the color space is omitted. If I do, I may just assume sRGB.

I won't be doing any gamut mapping, at least not right now. We'll just clip for now.

We handle:

I'm looking into adding the following via the color(space 1 2 3 / a) format:

Currently, a module will define the color classes and match function (matches the color syntax given to it, it doesn't search).

Classes can covert between each other (and sometimes do to perform certain color functions).

"""Colors."""
from .hsv import _HSV
from .srgb import _SRGB
from .hsl import _HSL
from .hwb import _HWB
from .lab import _LAB
from .lch import _LCH
from ..matcher import color_match, color_fullmatch

__all__ = ("HSV", "SRGB", "HSL", "HWB", "LAB", "LCH")

SPACES = frozenset({"srgb", "hsl", "hsv", "hwb", "lch", "lab"})

CS_MAP = {}

class HSV(_HSV):
    """HSV color class."""

    spaces = CS_MAP

class SRGB(_SRGB):
    """RGB color class."""

    spaces = CS_MAP

class HSL(_HSL):
    """HSL color class."""

    spaces = CS_MAP

class HWB(_HWB):
    """HWB color class."""

    spaces = CS_MAP

class LAB(_LAB):
    """HWB color class."""

    spaces = CS_MAP

class LCH(_LCH):
    """HWB color class."""

    spaces = CS_MAP

SUPPORTED = (HSV, HSL, HWB, LAB, LCH, SRGB)
for obj in SUPPORTED:
    CS_MAP[obj.space()] = obj

def colorgen(string, spaces=SPACES):
    """Match a color and return a match object."""

    return color_fullmatch(string, SUPPORTED, SPACES)

def colorgen_match(string, start=0, fullmatch=False, spaces=SPACES):
    """Match a color and return a match object."""

    return color_match(string, start, fullmatch, SUPPORTED, SPACES)

It can be noted that colors could be excluded by limiting the color spaces being used.

Theoretically, a user can subclass colors and provide their module. For instance, a user could subclass _SRGB to support #AARRGGBB instead of the usual #RRGGBBAA. Bundle it up and exclude color spaces they don't care about, but currently, all colors are usually at least defined as we allow color mixing in other color spaces, or determining minimum contrast in the sRGB color space. For color previews though, not all of that is needed.

Currently, this library is not meant to manage the regions in Sublime, it is agnostic to Sublime. You give it a string, and it gives you a color object.

>>> colorcss("red")
color(srgb 1 0 0 / 1)

Since it is in the sRGB color space, it will print as an rgb color:

>>> colorcss("red").to_string()
'rgb(255 0 0)'

Output string is configured by options. Users can basically define these in the settings and then we pass them to the object:

>>> colorcss('red').to_string(**options)
'#f00'

The generic form is a great way to store the color as it provides all the info we need in a simplistic form. So we could save away just the representation of the color.

It is currently working and is allowing me to gut a bunch of code in ColorHelper as it is all now handled in a cleaner form in the dependency.

facelessuser commented 4 years ago

@rwols if you have an idea of how an asynchronous wrapper would work (injecting phantoms) and would like to implement one, I'd be happy to try and piece it all together with the color library.

I still need to finish the color library, test it out, further test out the feasibility of customization with it etc.

rwols commented 4 years ago

I'm going off on a tangent here, but

Classes can covert between each other (and sometimes do to perform certain color functions).

How do you convert from sRGB to Lab without knowing the whitepoint? Or do you assume D65? Something to think about because Lab is whitepoint-independent, whereas all other color spaces mentioned are relative to a whitepoint. I'm unsure if these details really matter though.

Currently, this library is not meant to manage the regions in Sublime, it is agnostic to Sublime. You give it a string, and it gives you a color object.

It is currently working and is allowing me to gut a bunch of code in ColorHelper as it is all now handled in a cleaner form in the dependency.

Ah, cool. I had hoped I could actually reuse the color picker and the color box phantoms from ColorHelper. Hence my proposal for a vague idea of what the interface should look like.

if you have an idea of how an asynchronous wrapper would work (injecting phantoms) and would like to implement one, I'd be happy to try and piece it all together with the color library.

In due time

facelessuser commented 4 years ago

How do you convert from sRGB to Lab without knowing the whitepoint? Or do you assume D65?

Yup, we assume D65 and convert to D60 as we go into XYZ and convert to Lab and then to LCH. We are basically following this: https://www.w3.org/TR/css-color-4/#color-conversion-code.

There is still a lot of things that could be better. Like fitting colors from a large color space to a small color space (LCH -> sRGB). Gamut mapping isn't something I've gotten into much, so we just clip for now, but going from a small color space to a larger one is fine, and as long as you are in the gamut of the smaller color space while in the larger color space, it should convert back fine (with potential for slight floating point differences due to conversions).

There is still a lot to do as I polish up the lib. But I stuffed it in ExportHtml and can generate HTML views for Sublime Text without issue (with a Sublime color-mod layer). I can highlight colors in sublime-syntax files, CSS, HTML. Technically you can highlight colors in any files. I haven't tested, nor implemented handling custom color formats, that should technically work once I put some code in to handle it 🤞. I haven't done too much work dealing in your area of interest. I'm figuring out features to keep, features to chuck, stuff like that.

Ah, cool. I had hoped I could actually reuse the color picker and the color box phantoms from ColorHelper. Hence my proposal for a vague idea of what the interface should look like.

Reusing color picker should be possible I think. I am reworking that and fixing some issues it had, but I think that should be easy enough.

facelessuser commented 4 years ago

All planned color spaces are in the lib now. That was a lot of work. Probably still need to run it all through more extensive testing, but glad to have it all in.

Also, we no longer clip colors by default. We now have a "fit" function to fit the color in the color space if it is out of gamut. Now the fitting currently just clips, but the user can control whether they wish to clip or not. In the future, we can maybe provide a gamut mapping function, but I'll have to write some color distancing functions and such before that happens. That isn't entirely needed right now to get something out.

I think it may be good for ColorHelper (if the user doesn't want to clip colors, to render some kind of out of color gamut box.

facelessuser commented 4 years ago

It is possible we could allow different whitepoints for LCH or Lab, if that matters. If we did that, we'd have to start passing around the whitepoint for the various colors when doing conversions. A user could simply override the Lab or LCH object with the whitepoint of their choice and would convert accordingly. Not an impossible task. I don't think I'm going to worry about it right now, but something to consider in the future.

The whitepoint only really matters with conversion, and if you want to represent the color in some visual form as we can't natively represent the color in the LCH space (speaking from a Sublime perspective). Image previews must convert back to RGB, but even if we used pure CSS in HTML, Sublime is still constrained to representations of the RGB color space (HSL, HWB).

Granted, the color library is agnostic to Sublime, and could be used for stuff outside of Sublime, so maybe it is worth the effort at some point to add that in.

rwols commented 4 years ago

It’s not ideal if each color variable would have its own whitepoint. Usually an entire image has one single whitepoint.

rwols commented 4 years ago

Well, seeing as all of these colors are classes I don't think you're aiming for image transformations. So my point above doesn't really matter.

facelessuser commented 4 years ago

It’s not ideal if each color variable would have its own whitepoint. Usually an entire image has one single whitepoint.

I'm not sure what you are meaning by this. You could convert an image just fine. You would probably create an LCH or Lab class with a different white point and use that for your task. You wouldn't use a different whitepoint per pixel or anything like that. It's really only needed when you need to convert but every color space has a whitepoint even if we aren't already explicitly defining it in the class.

sRGB uses D65, but ProPhoto uses D50. They are both "RGB", but different whitespace, and and gamma correction. I made a mistake earlier and stated LCH and Lab use D60, I meant D50. I guess to be specific, we are implementing CIE Lab and CIE LCH.

So you have to know what the whitepoint is or none of the conversions will work. For instance, to go from ProPhoto to sRGB, we have to remove gamma correction, convert to XYZ, change the whitepoint, and go back to rgb and apply the appropriate gamma correcter per spec.

We also get the luminance from the XYZ color space (the Y channel), so to get that, you need to know the whitepoint during conversion too.

The current idea is that we have color classes derived from a base color class. This way you can just deal with the color you desire, but if you need to convert, the library provides references to each supported class so you can properly convert. The conversion currently just calls directly into a separate convert library that translates the coordinates, then they are fed to the appropriate class and returned. If you are working on a project that requires a different LCH (one with a different white point), you'd just define your own module that imports the colors (overriding LCH and Lab with yours). But the convert library currently assumes CIE Lab and LCH, so if it accepted the white point, instead of assuming it, it would allow the conversions to work.

In short, each color is already carrying around a whitepoint, but in many places, it is implied and handled in the current conversion. I wouldn't expect it to be dynamically swapped out in a single task.

I'm not sure if my current design is the best or not, but mainly how I am currently approaching it. I'm open to suggestions.

facelessuser commented 4 years ago

It's possible we could just provide a simple whitepoint conversion. You convert to CIE Lab or LCH and apply a different whitepoint. That would save the user from having to provide a class to do this. But we'd still have to track the whitepoint to ensure we convert it back to the expected one for the other colors conversions to work.

facelessuser commented 4 years ago

Maybe it'll make more sense to wrap all of this in a class opposed to modules.

color = Color("rgb(100% 30% 20%)")
color2 = color.convert("hsl")

The Color class will have an instance of whatever color was created. It will also contain any references to classes it can convert between.

Maybe you could even mutate the original color to other spaces...

Color.mutate("lch")

Something to think about...

facelessuser commented 4 years ago

Functionality is slowly starting to return. Moving forward, we'll be giving a more clear visual indication that a channel is at its end.

Screen Shot 2020-09-14 at 4 12 10 PM
facelessuser commented 4 years ago

Ignoring the artifacts in this gif, the color picker will now adjust the rainbow box according to the saturation of the target color. And since we no longer round trip back to RGB each time, we don't lose the hue angle like we used to when you reduce saturation to zero. We now stay in the HSL color space when in HSL.

picker

facelessuser commented 4 years ago

We will be dropping the old hex style color picker and only have the style shown in this thread which is, frankly, more helpful.

The only thing keeping us from implementing an LCH color picker is mode is that images are still represented in the RGB gamut. One day, when we get gamut mapping in, that isn't just clipping the colors, we can consider implementing the LCH picker.

facelessuser commented 4 years ago

The color picker itself is going to be agnostic to whatever color format is used a document. The plan is to ensure that, even if/when we get custom color formats working, they must be translated over to CSS style for us to process them in the picker. The picker will most likely, open up a document specific insert popup when inserting back into the document. If the document has no defined preferences, we will use some default insert options. I'll probably implement this portion next.

facelessuser commented 4 years ago
Screen Shot 2020-09-16 at 7 41 17 AM

We ended up implementing distancing using delta e200 (that was a very technical document to read). We borrowed the gamut fitting algorithm from https://github.com/LeaVerou/color.js. That project was created by the co-authors of the latest CSS color drafts. The algorithm itself is pretty simple once you have distancing in place. And it works pretty good, but does seem to get a little off around hue 100 for LCH. I think it has a little difficulty with yellow.

You can see this in their playground here:

Screen Shot 2020-09-16 at 7 51 04 AM

This may or may not be expected, but my impression seems to be that this is mainly just a weakness. They seem to acknowledge this, so this is most likely expected:

The default method is "lch.chroma" which means LCH hue and lightness remain constant while chroma is reduced until the color fits in gamut. Simply reducing chroma tends to produce good results for most colors, but most notably fails on yellows:

Reference: https://colorjs.io/docs/gamut-mapping.html

So, ours will basically act in the same manner:

Screen Shot 2020-09-16 at 8 10 55 AM

Still double-checking corner cases, but it seems we are at least matching how they fit colors.

facelessuser commented 4 years ago

Interestingly, I don't think the color intensity is differences is specific to yellow, I see varying degrees (pun not intended 🙂) when approaching other hue angles. The important part is that a lot of what makes the color is preserved, even if lightness doesn't always get preserved. I'll most likely allow the user to specify whether they want to see colors "fit", "clipped", or just show an out of gamut preview if we can't properly display the color.

The color picker though, we are going to leave in the RGB space for now. Any Lch color not within range would get represented in not the truest of ways.

facelessuser commented 4 years ago

On a side note, we did identify a bug in the gamut fitting algorithm if negative chroma is provided in Lch. We don't exactly hit it like colorjs does (this is because we calculate chroma from Lab's a and b value opposed to just taking the chroma a user provides), but even so, I imagine this should probably get clamped. I am interested to see how they approach the fix.

facelessuser commented 4 years ago

Out of gamut colors can now be shown by either not showing a preview:

Screen Shot 2020-09-16 at 5 29 39 PM

Showing a clipped version:

Screen Shot 2020-09-16 at 5 31 01 PM

or showing a color that has been "fit":

Screen Shot 2020-09-16 at 5 31 51 PM

Coupled with ST4's hover titles, it is easy to check if the color is out of gamut.

We'll integrate this in the color info popup as well. Also in the new color edit command shown in previous posts.

The color picker will force colors into gamut so you can actually pick a color.

facelessuser commented 4 years ago

We've broken out preview from the main dialog stuff. Preview logic is now self contained and decoupled from all the helper dialogs and panels. The big request to have a simple class that colors can be sent to I am still not sure about. But as we get closer to finishing, we can look into that.

We used to popup a palette box if you started typing a color rgb(|). Currently, that functionality has been removed. We could bring it back, but right now I'm going to to try and get everything else solid first.

facelessuser commented 3 years ago

Adjusting the color library. We now will wrap the color spaces in a single color object. The color object can only represent one color space at a time, but you can convert to other color spaces, spawning new color objects if desired. We will allow it to convert. The class can be mutated to other color spaces, and I may even allow them to convert "in place".

I think we are fast approaching a final solution for the underlying color library.

facelessuser commented 3 years ago

Finally, it is possible to create custom colors that override one or more color spaces with custom format recognition and outputs. This is a simple case where we override sRGB to see #RRGGBBAA as #AARRGGBB.

custom

Now, assuming people want to invest the time, they can make ColorHelper recognize any of the supported color spaces in any file using whatever format they want. This was a big step and was one of the ultimate goals to make it more flexible, allowing me to personally spend less time hacking in more and more complex code to handle different formats of the same color, just so ColorHelper can work in <insert your file format here>.

facelessuser commented 3 years ago

The new color edit panel is going to be pretty nice. A quick way to edit color and see live updates. It also allows you to mix colors in different color spaces at your desired percentage.

mix

facelessuser commented 3 years ago

Any work to create an async special class that can be used by anyone is going to have to be done as a separate issue. I've spent the last number of weeks doing an extensive overhaul to ColorHelper to improve behavior add long-overdue features and make it more flexible and easier for me to maintain.

Frankly, I'm going to need a break 😆. But I'm excited to make a beta available and start getting feedback. I'm going to close this issue for now and open a new beta issue once it is setup for testing.