RGB blending for PrimarySelection → SecondarySelection face colors

Hello

I mainly use Dracula as my color theme for my CLI tools.

When I ported this theme to Kakoune a few years ago, I arbitrarily decided that the primary selection would be pink and the secondary selections would be purple:

black="rgb:282a36"
pink="rgb:ff79c6"
purple="rgb:bd93f9"
cyan="rgb:8be9fd"
orange="rgb:ffb86c"

…

face global PrimarySelection $black,$pink
face global PrimaryCursor $black,$cyan
face global PrimaryCursorEol $black,$cyan

face global SecondarySelection $black,$purple
face global SecondaryCursor $black,$orange
face global SecondaryCursorEol $black,$orange

Which visually looks like this:

On the screenshot, 3 lines are selected, the last one is the main selection.
I was never truly satisfied by this choice, but it was “good enough”.

Blend()

Recently, there were discussions about Neovim and “fake transparency”. And so I thought : “Why do color themes should be restrained to the exact few colors they define? What about playing with derived colors?”

So the idea is: what if the primary selection remain pink, but the secondary selections would be a “semi transparent pink” instead?

Here’s what it looks now:

On the screenshot above, I computed colors which are at midpoint (0.5) between the pink and the blackish background and use them for secondary selections.

To do so, I wrote this ugly shell function:

blend () {
    hexR1=$(printf "$1" | cut -c 5-6)
    hexG1=$(printf "$1" | cut -c 7-8)
    hexB1=$(printf "$1" | cut -c 9-10)
    decR1=$(printf "%d" 0x$hexR1)
    decG1=$(printf "%d" 0x$hexG1)
    decB1=$(printf "%d" 0x$hexB1)

    hexR2=$(printf "$2" | cut -c 5-6)
    hexG2=$(printf "$2" | cut -c 7-8)
    hexB2=$(printf "$2" | cut -c 9-10)
    decR2=$(printf "%d" 0x$hexR2)
    decG2=$(printf "%d" 0x$hexG2)
    decB2=$(printf "%d" 0x$hexB2)

    decR=$(echo "$decR1 + $3 * ($decR2 - $decR1)" | bc)
    decG=$(echo "$decG1 + $3 * ($decG2 - $decG1)" | bc)
    decB=$(echo "$decB1 + $3 * ($decB2 - $decB1)" | bc)

    intR=$(printf "%0.f" $decR)
    intG=$(printf "%0.f" $decG)
    intB=$(printf "%0.f" $decB)

    printf "rgb:%x%x%x" $intR $intG $intB
}

It’s a proof of concept, and I’m sure a shell guru can produce the same result in half the code. :wink:

Then to use it:

pink50=$(blend $black $pink 0.5)
cyan50=$(blend $black $cyan 0.5)

…

face global SecondarySelection $black,$pink50
face global SecondaryCursor $black,$cyan50
face global SecondaryCursorEol $black,$cyan50

Here’s the complete script .config/kak/colors/dracula.kak:

# dracula theme
# https://draculatheme.com/

evaluate-commands %sh{
    blend () {
        hexR1=$(printf "$1" | cut -c 5-6)
        hexG1=$(printf "$1" | cut -c 7-8)
        hexB1=$(printf "$1" | cut -c 9-10)
        decR1=$(printf "%d" 0x$hexR1)
        decG1=$(printf "%d" 0x$hexG1)
        decB1=$(printf "%d" 0x$hexB1)

        hexR2=$(printf "$2" | cut -c 5-6)
        hexG2=$(printf "$2" | cut -c 7-8)
        hexB2=$(printf "$2" | cut -c 9-10)
        decR2=$(printf "%d" 0x$hexR2)
        decG2=$(printf "%d" 0x$hexG2)
        decB2=$(printf "%d" 0x$hexB2)

        decR=$(echo "$decR1 + $3 * ($decR2 - $decR1)" | bc)
        decG=$(echo "$decG1 + $3 * ($decG2 - $decG1)" | bc)
        decB=$(echo "$decB1 + $3 * ($decB2 - $decB1)" | bc)

        intR=$(printf "%0.f" $decR)
        intG=$(printf "%0.f" $decG)
        intB=$(printf "%0.f" $decB)

        printf "rgb:%x%x%x" $intR $intG $intB
    }

    black="rgb:282a36"
    gray="rgb:44475a"
    white="rgb:f8f8f2"

    pink="rgb:ff79c6"
    purple="rgb:bd93f9"
    blue="rgb:6272a4"
    cyan="rgb:8be9fd"
    green="rgb:50fa7b"
    yellow="rgb:f1fa8c"
    orange="rgb:ffb86c"
    red="rgb:ff5555"

    pink50=$(blend $black $pink 0.5)
    cyan50=$(blend $black $cyan 0.5)

    echo "
         face global value $green
         face global type $purple
         face global variable $red
         face global function $red
         face global module $red
         face global string $yellow
         face global error $red
         face global keyword $cyan
         face global operator $orange
         face global attribute $pink
         face global comment $blue+i
         face global meta $red
         face global builtin $white+b

         face global title $red
         face global header $orange
         face global bold $pink
         face global italic $purple
         face global mono $green
         face global block $cyan
         face global link $green
         face global bullet $green
         face global list $white

         face global Default $white,$black

         face global PrimarySelection $black,$pink
         face global PrimaryCursor $black,$cyan
         face global PrimaryCursorEol $black,$cyan

         face global SecondarySelection $black,$pink50
         face global SecondaryCursor $black,$cyan50
         face global SecondaryCursorEol $black,$cyan50

         face global MatchingChar $black,$blue
         face global Search $blue,$green
         face global CurrentWord $white,$blue

         # listchars
         face global Whitespace $gray,$black+f
         # ~ lines at EOB
         face global BufferPadding $gray,$black

         face global LineNumbers $gray,$black
         # must use -hl-cursor
         face global LineNumberCursor $white,$gray+b
         face global LineNumbersWrapped $gray,$black+i

         # when item focused in menu
         face global MenuForeground $blue,$white+b
         # default bottom menu and autocomplete
         face global MenuBackground $white,$blue
         # complement in autocomplete like path
         face global MenuInfo $cyan,$blue
         # clippy
         face global Information $yellow,$gray
         face global Error $black,$red

         # all status line: what we type, but also client@[session]
         face global StatusLine $white,$black
         # insert mode, prompt mode
         face global StatusLineMode $black,$green
         # message like '1 sel'
         face global StatusLineInfo $purple,$black
         # count
         face global StatusLineValue $orange,$black
         face global StatusCursor $white,$blue
         # like the word 'select:' when pressing 's'
         face global Prompt $black,$green
    "
}

Takeaway

Obviously you don’t have to use the dracula theme to benefit from this technique. You can just include the blend() function in your favorite color theme and then generate new colors to be used to mark subtle gradient effects. For instance, this could be a good idea to used dim colors for phantom selections https://github.com/occivink/kakoune-phantom-selection

Don’t hesitate to share your ideas / screenshots.

1 Like

Using HSL colors makes color variation and creation very easy.

What do you think @Delapouite of Kakoune supporting it.

For reference: DraculaColor Palette.

You can also remove the need of bc with:

decR=$((decR1 + $3 * (decR2 - decR1)))
decG=$((decG1 + $3 * (decG2 - decG1)))
decB=$((decB1 + $3 * (decB2 - decB1)))

(Have you looked at some guide to implement the color variation?)

Note that just taking RGB values and averaging them does not give you the colour half-way between them, because RGB values are non-linear. You might expect the half-way point from rgb:000000 to rgb:ffffff to be rgb:808080, but that colour is much too dark. Visually, the half-way point is more like rgb:bababa.

To do colour math correctly, you have to convert each R, G, B channel to a linear value, do the math, then convert it back. The actual conversion is a bit intricate, but a reasonable approximation is to use a gamma value of 2.2:

def srgb_blend(srgb_from, srgb_to, ratio):
    # sRGB normally measures brightness from 0-255,
    # but for mathematical reasons we want to use 0.0-1.0.
    normalised_srgb_from = srgb_from / 255.0
    normalised_srgb_to = srgb_to / 255.0

    # sRGB measures brightness in non-linear fashion,
    # with a gamma of about 2.2,
    # so we must remove that correction
    # to restore linearity.
    linear_from = pow(normalised_srgb_from, 2.2)
    linear_to = pow(normalised_srgb_to, 2.2)

    # Now we can blend as normal.
    linear_result = linear_from + ratio * (linear_to - linear_from)

    # We must re-apply the gamma correction to our result.
    normalised_srgb_result = pow(linear_result, 1/2.2)

    # We must re-convert our range back to 0-255
    srgb_result = int(normalised_srgb_result * 255)

    # And we're done!
    return srgb_result

If we call that function and ask it to blend 0 (black) and 255 (white) with a 50% ratio:

>>> srgb_blend(0, 255, 0.5)
186

…we get the result we wanted.

POSIX sh does not support exponentiation or floating point math, so you can’t do this conversion in pure shell. bc does support exponentiation and arbitrary-precision values, so it should be possible, and awk supports floating point numbers, so it should be possible there too.

If you need to use some other language where arbitrary exponentiation isn’t possible, you can round gamma=2.4 to gamma=2.0, and say normalised_srgb = sqrt(linear).

2 Likes

Thanks for your insight @Screwtapello !

As you demonstrated, calculations on colors is a bit more involved that the dumb function I wrote in shell. I’ll try to adapt your Python code into an awk one.

@alexherbo2 You’re right, dealing with HSL colors simplifies their manipulation. By adjusting the S or the L we can almost emulate what I tried to do. The difference is that if wet set L to 0, it will be pure black and not the “dracula black”. I don’t know if that would be noticable for high values.

Also, I’m afraid adding support for other colors encoding like HSL or HSV right into Kakoune core would result in bloat that may not been justified.

Maybe the right move here is just to provide efficient and POSIXy functions to convert from/to rgb ←→ hsl as a plugin.

@Delapouite https://github.com/sharkdp/pastel

Thanks a lot @alexherbo2 !
I had totally forgotten that tool (turns out it was already in my GH stars). It’s the perfect one for what’s needed here. There’s just a bit of cut/concat to do with the rgb: prefix.