<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P125-colour-mixer/ | updated: 2026-10-17 -->

# P125 Colour mixer

Colours as #RRGGBB mixed, added with clamping, scaled and back, graded and eased one channel at a time in whole numbers, each share truncated and rounded half up side by side.

EML-P project `projects/colour-mixer` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/colour-mixer` runs it and `eml project verify projects/colour-mixer` replays every session under CPython (two hash seeds) and in the interpreter; the site build replays every session in the interpreter again and publishes a session only if its screen matches.

Built on verified corpus cases: rgb-palette-blender (https://efficientnewlanguage.org/cases/165-rgb-palette-blender/).

## Sessions

### bad-input - interpreter: byte-equal to the golden

Input:

```text
0
x
2

2
pink
2
#12345G
2
#1234
2
black

2
black
white

2
black
white
101
2
black
white
x
4
rust
0
4
rust
401
5
black
white
1
5
black
white
13
6
black
white
0
6
black
white
100
6
grey
grey
50
7
```

Screen:

```text
== Colour mixer ==
Colours as red, green and blue from 0 to 255, written #RRGGBB, worked one channel at a time in whole numbers.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 0
Pick a number from 1 to 7.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> x
Pick a number from 1 to 7.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 2
first colour> 
Cancelled.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 2
first colour> pink
Type #RRGGBB in hex, such as #C86432, or a colour from the palette.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 2
first colour> #12345G
Type #RRGGBB in hex, such as #C86432, or a colour from the palette.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 2
first colour> #1234
Type #RRGGBB in hex, such as #C86432, or a colour from the palette.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 2
first colour> black
second colour> 
Cancelled.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 2
first colour> black
second colour> white
percent of the second (0 to 100)> 
Cancelled.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 2
first colour> black
second colour> white
percent of the second (0 to 100)> 101
Type a whole number from 0 to 100.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 2
first colour> black
second colour> white
percent of the second (0 to 100)> x
Type a whole number from 0 to 100.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 4
colour> rust
brightness percent (1 to 400)> 0
Type a whole number from 1 to 400.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 4
colour> rust
brightness percent (1 to 400)> 401
Type a whole number from 1 to 400.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 5
first colour> black
second colour> white
colours in the gradient (2 to 12)> 1
Type a whole number from 2 to 12.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 5
first colour> black
second colour> white
colours in the gradient (2 to 12)> 13
Type a whole number from 2 to 12.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 6
first colour> black
second colour> white
percent of the way each step (1 to 99)> 0
Type a whole number from 1 to 99.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 6
first colour> black
second colour> white
percent of the way each step (1 to 99)> 100
Type a whole number from 1 to 99.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 6
first colour> grey
second colour> grey
percent of the way each step (1 to 99)> 50
  Already there.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 7
Bye.
```

### basic - interpreter: byte-equal to the golden

Input:

```text
1
2
black
white
50
2
rust
teal
30
3
#C8B4C8
#3C3C3C
3
white
red
4
rust
200
4
#C9652F
50
5
black
white
5
6
black
white
20
6
rust
#14A096
10
7
```

Screen:

```text
== Colour mixer ==
Colours as red, green and blue from 0 to 255, written #RRGGBB, worked one channel at a time in whole numbers.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 1
Palette:
  black   #000000 (0, 0, 0)
  white   #FFFFFF (255, 255, 255)
  red     #C81E28 (200, 30, 40)
  orange  #F08C14 (240, 140, 20)
  teal    #148C96 (20, 140, 150)
  purple  #7828A0 (120, 40, 160)
  rust    #C86432 (200, 100, 50)
  grey    #808080 (128, 128, 128)

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 2
first colour> black
second colour> white
percent of the second (0 to 100)> 50
Truncated: #7F7F7F (127, 127, 127)
Half up:   #808080 (128, 128, 128)
Truncation is 1 lower in red, green and blue: the share left over is dropped, never rounded up.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 2
first colour> rust
second colour> teal
percent of the second (0 to 100)> 30
Truncated: #927050 (146, 112, 80)
Half up:   #927050 (146, 112, 80)

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 3
first colour> #C8B4C8
second colour> #3C3C3C
Sum: #FFF0FF (255, 240, 255)
Clamped at 255: red 260 and blue 260. The other channels kept their sums, so the balance between channels shifts.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 3
first colour> white
second colour> red
Sum: #FFFFFF (255, 255, 255)
Clamped at 255: red 455, green 285 and blue 295. Every channel is clamped: the sum is white.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 4
colour> rust
brightness percent (1 to 400)> 200
Times 200%: #FFC864 (255, 200, 100)
Back, times 100/200: #806432 (128, 100, 50)
  red was clamped at 255: its 200 is lost.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 4
colour> #C9652F
brightness percent (1 to 400)> 50
Times 50%: #653318 (101, 51, 24)
Back, times 100/50: #CA6630 (202, 102, 48)
  red comes back as 202, not 201: scaling down to 101 rounded the detail away.
  green comes back as 102, not 101: scaling down to 51 rounded the detail away.
  blue comes back as 48, not 47: scaling down to 24 rounded the detail away.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 5
first colour> black
second colour> white
colours in the gradient (2 to 12)> 5
  step  truncated                 half up
  0     #000000 (0, 0, 0)         #000000 (0, 0, 0)
  1     #3F3F3F (63, 63, 63)      #404040 (64, 64, 64)
  2     #7F7F7F (127, 127, 127)   #808080 (128, 128, 128)
  3     #BFBFBF (191, 191, 191)   #BFBFBF (191, 191, 191)
  4     #FFFFFF (255, 255, 255)   #FFFFFF (255, 255, 255)
  Half up is 1 higher in 6 of the 15 channel values: those where the share left over is a half or more.

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 6
first colour> black
second colour> white
percent of the way each step (1 to 99)> 20
  truncating each step: stops after 21 steps at #FBFBFB (251, 251, 251), short of #FFFFFF: the steps left are too small to round to 1
  rounding each step half up: stops after 21 steps at #FDFDFD (253, 253, 253), short of #FFFFFF: the steps left are too small to round to 1

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 6
first colour> rust
second colour> #14A096
percent of the way each step (1 to 99)> 10
  truncating each step: stops after 33 steps at #1D978D (29, 151, 141), short of #14A096: the steps left are too small to round to 1
  rounding each step half up: stops after 33 steps at #189C92 (24, 156, 146), short of #14A096: the steps left are too small to round to 1

1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit
choice> 7
Bye.
```

## Modules

### main.eml

```eml
# P125 colour mixer: named colours, mixing two, adding with clamping,
# scaling and back, gradients and easing, each rounded two ways.
import colour

def plain(s):
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def pad(s, n):
    s => out
    while len(out) < n:
        out + " " => out
    return out

def channels(c):
    return "(" + str(c[0]) + ", " + str(c[1]) + ", " + str(c[2]) + ")"

def text(c):
    return colour.hex_text(c) + " " + channels(c)

def ask_colour(prompt):
    plain(input(prompt)) => t
    if t == "":
        "Cancelled." ^0
        return []
    colour.parse(t) => c
    if len(c) == 0:
        "Type #RRGGBB in hex, such as #C86432, or a colour from the palette." ^0
    return c

def ask_int(prompt, low, high):
    plain(input(prompt)) => t
    if t == "":
        "Cancelled." ^0
        return 0 - 1
    if len(t) == 0 or len(t) > 3:
        ("Type a whole number from " + str(low) + " to " + str(high) + ".") ^0
        return 0 - 1
    for ch in t:
        if ch < "0" or ch > "9":
            ("Type a whole number from " + str(low) + " to " + str(high) + ".") ^0
            return 0 - 1
    if int(t) < low or int(t) > high:
        ("Type a whole number from " + str(low) + " to " + str(high) + ".") ^0
        return 0 - 1
    return int(t)

def show_palette():
    colour.palette() => p
    "Palette:" ^0
    for name in p:
        ("  " + pad(name, 8) + text(p[name])) ^0

def names():
    return ["red", "green", "blue"]

def two():
    ask_colour("first colour> ") => a
    if len(a) == 0:
        return []
    ask_colour("second colour> ") => b
    if len(b) == 0:
        return []
    return [a, b]

def do_mix():
    two() => ab
    if len(ab) == 0:
        return
    ask_int("percent of the second (0 to 100)> ", 0, 100) => w
    if w < 0:
        return
    colour.mix(ab[0], ab[1], w, "trunc") => t
    colour.mix(ab[0], ab[1], w, "half") => h
    ("Truncated: " + text(t)) ^0
    ("Half up:   " + text(h)) ^0
    [] => lower
    for k in [0:2]:
        if t[k] != h[k]:
            lower + [names()[k]] => lower
    if len(lower) > 0:
        ("Truncation is 1 lower in " + list_text(lower) + ": the share left over is dropped, never rounded up.") ^0

def list_text(xs):
    if len(xs) == 1:
        return xs[0]
    if len(xs) == 2:
        return xs[0] + " and " + xs[1]
    return xs[0] + ", " + xs[1] + " and " + xs[2]

def do_add():
    two() => ab
    if len(ab) == 0:
        return
    colour.add(ab[0], ab[1]) => r
    ("Sum: " + text(r[0])) ^0
    [] => over
    for k in [0:2]:
        if r[1][k] > 255:
            over + [names()[k] + " " + str(r[1][k])] => over
    if len(over) == 3:
        ("Clamped at 255: " + list_text(over) + ". Every channel is clamped: the sum is white.") ^0
    elif len(over) > 0:
        ("Clamped at 255: " + list_text(over) + ". The other channels kept their sums, so the balance between channels shifts.") ^0

def do_scale():
    ask_colour("colour> ") => c
    if len(c) == 0:
        return
    ask_int("brightness percent (1 to 400)> ", 1, 400) => f
    if f < 0:
        return
    colour.scale(c, f) => r
    colour.unscale(r[0], f) => back
    ("Times " + str(f) + "%: " + text(r[0])) ^0
    ("Back, times 100/" + str(f) + ": " + text(back)) ^0
    for k in [0:2]:
        if back[k] != c[k]:
            if r[1][k]:
                ("  " + names()[k] + " was clamped at 255: its " + str(c[k]) + " is lost.") ^0
            else:
                ("  " + names()[k] + " comes back as " + str(back[k]) + ", not " + str(c[k]) + ": scaling down to " + str(r[0][k]) + " rounded the detail away.") ^0
    if back == c:
        "  Every channel came back." ^0

def do_gradient():
    two() => ab
    if len(ab) == 0:
        return
    ask_int("colours in the gradient (2 to 12)> ", 2, 12) => n
    if n < 0:
        return
    colour.gradient(ab[0], ab[1], n, "trunc") => t
    colour.gradient(ab[0], ab[1], n, "half") => h
    "  step  truncated                 half up" ^0
    for i in [0:n - 1]:
        ("  " + pad(str(i), 6) + pad(text(t[i]), 26) + text(h[i])) ^0
    0 => diff
    for i in [0:n - 1]:
        for k in [0:2]:
            if t[i][k] != h[i][k]:
                diff + 1 => diff
    if diff > 0:
        ("  Half up is 1 higher in " + str(diff) + " of the " + str(3 * n) + " channel values: those where the share left over is a half or more.") ^0

def do_ease():
    two() => ab
    if len(ab) == 0:
        return
    ask_int("percent of the way each step (1 to 99)> ", 1, 99) => t
    if t < 0:
        return
    if ab[0] == ab[1]:
        "  Already there." ^0
        return
    for mode in ["trunc", "half"]:
        colour.ease(ab[0], ab[1], t, mode, 60) => steps
        steps[len(steps) - 1] => last
        "truncating each step" => label
        if mode == "half":
            "rounding each step half up" => label
        if last == ab[1]:
            ("  " + label + ": reaches " + colour.hex_text(ab[1]) + " in " + str(len(steps) - 1) + " steps") ^0
        elif len(steps) == 61:
            ("  " + label + ": still moving after 60 steps, at " + text(last)) ^0
        else:
            ("  " + label + ": stops after " + str(len(steps) - 1) + " steps at " + text(last) + ", short of " + colour.hex_text(ab[1]) + ": the steps left are too small to round to 1") ^0

"== Colour mixer ==" ^0
"Colours as red, green and blue from 0 to 255, written #RRGGBB, worked one channel at a time in whole numbers." ^0
True => running
while running:
    "" ^0
    "1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit" ^0
    plain(input("choice> ")) => choice
    if choice == "1":
        show_palette()
    elif choice == "2":
        do_mix()
    elif choice == "3":
        do_add()
    elif choice == "4":
        do_scale()
    elif choice == "5":
        do_gradient()
    elif choice == "6":
        do_ease()
    elif choice == "7":
        False => running
    else:
        "Pick a number from 1 to 7." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import colour

def plain(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def pad(s, n):
    out = s
    while len(out) < n:
        out = out + " "
    return out

def channels(c):
    return "(" + str(c[0]) + ", " + str(c[1]) + ", " + str(c[2]) + ")"

def text(c):
    return colour.hex_text(c) + " " + channels(c)

def ask_colour(prompt):
    t = plain(input(prompt))
    if t == "":
        print("Cancelled.")
        return []
    c = colour.parse(t)
    if len(c) == 0:
        print("Type #RRGGBB in hex, such as #C86432, or a colour from the palette.")
    return c

def ask_int(prompt, low, high):
    t = plain(input(prompt))
    if t == "":
        print("Cancelled.")
        return 0 - 1
    if len(t) == 0 or len(t) > 3:
        print("Type a whole number from " + str(low) + " to " + str(high) + ".")
        return 0 - 1
    for ch in t:
        if ch < "0" or ch > "9":
            print("Type a whole number from " + str(low) + " to " + str(high) + ".")
            return 0 - 1
    if int(t) < low or int(t) > high:
        print("Type a whole number from " + str(low) + " to " + str(high) + ".")
        return 0 - 1
    return int(t)

def show_palette():
    p = colour.palette()
    print("Palette:")
    for name in p:
        print("  " + pad(name, 8) + text(p[name]))

def names():
    return ["red", "green", "blue"]

def two():
    a = ask_colour("first colour> ")
    if len(a) == 0:
        return []
    b = ask_colour("second colour> ")
    if len(b) == 0:
        return []
    return [a, b]

def do_mix():
    ab = two()
    if len(ab) == 0:
        return
    w = ask_int("percent of the second (0 to 100)> ", 0, 100)
    if w < 0:
        return
    t = colour.mix(ab[0], ab[1], w, "trunc")
    h = colour.mix(ab[0], ab[1], w, "half")
    print("Truncated: " + text(t))
    print("Half up:   " + text(h))
    lower = []
    for k in range(0, 3):
        if t[k] != h[k]:
            lower = lower + [names()[k]]
    if len(lower) > 0:
        print("Truncation is 1 lower in " + list_text(lower) + ": the share left over is dropped, never rounded up.")

def list_text(xs):
    if len(xs) == 1:
        return xs[0]
    if len(xs) == 2:
        return xs[0] + " and " + xs[1]
    return xs[0] + ", " + xs[1] + " and " + xs[2]

def do_add():
    ab = two()
    if len(ab) == 0:
        return
    r = colour.add(ab[0], ab[1])
    print("Sum: " + text(r[0]))
    over = []
    for k in range(0, 3):
        if r[1][k] > 255:
            over = over + [names()[k] + " " + str(r[1][k])]
    if len(over) == 3:
        print("Clamped at 255: " + list_text(over) + ". Every channel is clamped: the sum is white.")
    elif len(over) > 0:
        print("Clamped at 255: " + list_text(over) + ". The other channels kept their sums, so the balance between channels shifts.")

def do_scale():
    c = ask_colour("colour> ")
    if len(c) == 0:
        return
    f = ask_int("brightness percent (1 to 400)> ", 1, 400)
    if f < 0:
        return
    r = colour.scale(c, f)
    back = colour.unscale(r[0], f)
    print("Times " + str(f) + "%: " + text(r[0]))
    print("Back, times 100/" + str(f) + ": " + text(back))
    for k in range(0, 3):
        if back[k] != c[k]:
            if r[1][k]:
                print("  " + names()[k] + " was clamped at 255: its " + str(c[k]) + " is lost.")
            else:
                print("  " + names()[k] + " comes back as " + str(back[k]) + ", not " + str(c[k]) + ": scaling down to " + str(r[0][k]) + " rounded the detail away.")
    if back == c:
        print("  Every channel came back.")

def do_gradient():
    ab = two()
    if len(ab) == 0:
        return
    n = ask_int("colours in the gradient (2 to 12)> ", 2, 12)
    if n < 0:
        return
    t = colour.gradient(ab[0], ab[1], n, "trunc")
    h = colour.gradient(ab[0], ab[1], n, "half")
    print("  step  truncated                 half up")
    for i in range(0, n):
        print("  " + pad(str(i), 6) + pad(text(t[i]), 26) + text(h[i]))
    diff = 0
    for i in range(0, n):
        for k in range(0, 3):
            if t[i][k] != h[i][k]:
                diff = diff + 1
    if diff > 0:
        print("  Half up is 1 higher in " + str(diff) + " of the " + str(3 * n) + " channel values: those where the share left over is a half or more.")

def do_ease():
    ab = two()
    if len(ab) == 0:
        return
    t = ask_int("percent of the way each step (1 to 99)> ", 1, 99)
    if t < 0:
        return
    if ab[0] == ab[1]:
        print("  Already there.")
        return
    for mode in ["trunc", "half"]:
        steps = colour.ease(ab[0], ab[1], t, mode, 60)
        last = steps[len(steps) - 1]
        label = "truncating each step"
        if mode == "half":
            label = "rounding each step half up"
        if last == ab[1]:
            print("  " + label + ": reaches " + colour.hex_text(ab[1]) + " in " + str(len(steps) - 1) + " steps")
        elif len(steps) == 61:
            print("  " + label + ": still moving after 60 steps, at " + text(last))
        else:
            print("  " + label + ": stops after " + str(len(steps) - 1) + " steps at " + text(last) + ", short of " + colour.hex_text(ab[1]) + ": the steps left are too small to round to 1")

print("== Colour mixer ==")
print("Colours as red, green and blue from 0 to 255, written #RRGGBB, worked one channel at a time in whole numbers.")
running = True
while running:
    print("")
    print("1) palette  2) mix  3) add  4) scale  5) gradient  6) ease  7) quit")
    choice = plain(input("choice> "))
    if choice == "1":
        show_palette()
    elif choice == "2":
        do_mix()
    elif choice == "3":
        do_add()
    elif choice == "4":
        do_scale()
    elif choice == "5":
        do_gradient()
    elif choice == "6":
        do_ease()
    elif choice == "7":
        running = False
    else:
        print("Pick a number from 1 to 7.")
print("Bye.")
```

### colour.eml

```eml
# P125 colour mixer - colours as three channels from 0 to 255, written as
# #RRGGBB. As in the corpus case rgb-palette-blender, every operation works
# one channel at a time, and adding clamps each channel on its own. All
# arithmetic is on whole numbers; the two ways of rounding a share are
# truncation (what int() of a positive share does) and half up.

def palette():
    return {"black": [0, 0, 0], "white": [255, 255, 255], "red": [200, 30, 40], "orange": [240, 140, 20], "teal": [20, 140, 150], "purple": [120, 40, 160], "rust": [200, 100, 50], "grey": [128, 128, 128]}

def hex_digit(c):
    "0123456789ABCDEF" => ds
    "0123456789abcdef" => ls
    for k in [0:15]:
        if ds[k] == c or ls[k] == c:
            return k
    return 0 - 1

def parse(text):
    # [r, g, b] from #RRGGBB, RRGGBB or a palette name; [] if neither
    palette() => p
    if text in p:
        return p[text]
    text => t
    if len(t) == 7 and t[0] == "#":
        t[1:7] => t
    if len(t) != 6:
        return []
    [] => out
    for k in [0:2]:
        hex_digit(t[2 * k]) => hi
        hex_digit(t[2 * k + 1]) => lo
        if hi < 0 or lo < 0:
            return []
        out + [16 * hi + lo] => out
    return out

def hex_text(c):
    "0123456789ABCDEF" => ds
    "#" => out
    for x in c:
        out + ds[div(x, 16)] + ds[x - 16 * div(x, 16)] => out
    return out

def div(p, q):
    # floor of p / q, for q > 0 and any whole p
    int(p / q) => f
    while f * q > p:
        f - 1 => f
    while (f + 1) * q <= p:
        f + 1 => f
    return f

def share(x, q, mode):
    # x / q for x >= 0: truncated, or rounded half up
    if mode == "trunc":
        return div(x, q)
    return div(2 * x + q, 2 * q)

def mix(a, b, w, mode):
    # w percent of b into a
    [] => out
    for k in [0:2]:
        out + [share(a[k] * (100 - w) + b[k] * w, 100, mode)] => out
    return out

def add(a, b):
    # [clamped sum, raw sums]
    [] => out
    [] => raw
    for k in [0:2]:
        raw + [a[k] + b[k]] => raw
        if a[k] + b[k] > 255:
            out + [255] => out
        else:
            out + [a[k] + b[k]] => out
    return [out, raw]

def scale(c, f):
    # [each channel times f percent, rounded half up and clamped at 255,
    #  which were clamped]
    [] => out
    [] => clamped
    for x in c:
        share(x * f, 100, "half") => y
        if y > 255:
            out + [255] => out
            clamped + [True] => clamped
        else:
            out + [y] => out
            clamped + [False] => clamped
    return [out, clamped]

def unscale(c, f):
    # each channel times 100 / f, rounded half up, for f > 0
    [] => out
    for x in c:
        out + [share(x * 100, f, "half")] => out
    return out

def gradient(a, b, n, mode):
    # n colours from a to b, the k-th weighted (n - 1 - k) : k
    [] => out
    for i in [0:n - 1]:
        [] => c
        for k in [0:2]:
            c + [share(a[k] * (n - 1 - i) + b[k] * i, n - 1, mode)] => c
        out + [c] => out
    return out

def toward(x, target, t, mode):
    # one easing step: t percent of the way, the step rounded toward zero
    # (truncation) or half up, by its size
    target - x => d
    if d >= 0:
        return x + share(d * t, 100, mode)
    return x - share((0 - d) * t, 100, mode)

def ease(a, b, t, mode, limit):
    # the colours from a, each step t percent of the way to b, until it
    # reaches b, stops moving, or limit steps
    [a] => out
    a => c
    for s in [1:limit]:
        [] => nxt
        for k in [0:2]:
            nxt + [toward(c[k], b[k], t, mode)] => nxt
        if nxt == c:
            return out
        nxt => c
        out + [c] => out
        if c == b:
            return out
    return out
```

Python projection of colour.eml:

```python
def palette():
    return {"black": [0, 0, 0], "white": [255, 255, 255], "red": [200, 30, 40], "orange": [240, 140, 20], "teal": [20, 140, 150], "purple": [120, 40, 160], "rust": [200, 100, 50], "grey": [128, 128, 128]}

def hex_digit(c):
    ds = "0123456789ABCDEF"
    ls = "0123456789abcdef"
    for k in range(0, 16):
        if ds[k] == c or ls[k] == c:
            return k
    return 0 - 1

def parse(text):
    p = palette()
    if text in p:
        return p[text]
    t = text
    if len(t) == 7 and t[0] == "#":
        t = t[1:7]
    if len(t) != 6:
        return []
    out = []
    for k in range(0, 3):
        hi = hex_digit(t[2 * k])
        lo = hex_digit(t[2 * k + 1])
        if hi < 0 or lo < 0:
            return []
        out = out + [16 * hi + lo]
    return out

def hex_text(c):
    ds = "0123456789ABCDEF"
    out = "#"
    for x in c:
        out = out + ds[div(x, 16)] + ds[x - 16 * div(x, 16)]
    return out

def div(p, q):
    f = int(p / q)
    while f * q > p:
        f = f - 1
    while (f + 1) * q <= p:
        f = f + 1
    return f

def share(x, q, mode):
    if mode == "trunc":
        return div(x, q)
    return div(2 * x + q, 2 * q)

def mix(a, b, w, mode):
    out = []
    for k in range(0, 3):
        out = out + [share(a[k] * (100 - w) + b[k] * w, 100, mode)]
    return out

def add(a, b):
    out = []
    raw = []
    for k in range(0, 3):
        raw = raw + [a[k] + b[k]]
        if a[k] + b[k] > 255:
            out = out + [255]
        else:
            out = out + [a[k] + b[k]]
    return [out, raw]

def scale(c, f):
    out = []
    clamped = []
    for x in c:
        y = share(x * f, 100, "half")
        if y > 255:
            out = out + [255]
            clamped = clamped + [True]
        else:
            out = out + [y]
            clamped = clamped + [False]
    return [out, clamped]

def unscale(c, f):
    out = []
    for x in c:
        out = out + [share(x * 100, f, "half")]
    return out

def gradient(a, b, n, mode):
    out = []
    for i in range(0, n):
        c = []
        for k in range(0, 3):
            c = c + [share(a[k] * (n - 1 - i) + b[k] * i, n - 1, mode)]
        out = out + [c]
    return out

def toward(x, target, t, mode):
    d = target - x
    if d >= 0:
        return x + share(d * t, 100, mode)
    return x - share((0 - d) * t, 100, mode)

def ease(a, b, t, mode, limit):
    out = [a]
    c = a
    for s in range(1, limit+1):
        nxt = []
        for k in range(0, 3):
            nxt = nxt + [toward(c[k], b[k], t, mode)]
        if nxt == c:
            return out
        c = nxt
        out = out + [c]
        if c == b:
            return out
    return out
```

## README

# P125 - Colour mixer

Colours as red, green and blue from 0 to 255, written #RRGGBB, mixed,
added, scaled, graded and eased one channel at a time in whole numbers,
with each share rounded two ways: truncated, and half up.

- `main.eml` - the menu, the palette, and the five operations
- `colour.eml` - the palette, reading and writing #RRGGBB, the two ways of
  rounding a share, and the operations

How each part works:

- As in the corpus case `rgb-palette-blender`, every operation works one
  channel at a time, and adding clamps each channel at 255 on its own.
  When some channels clamp and others do not, the balance between them
  shifts.
- Mixing w percent of one colour into another: truncation drops the share
  left over, so it is never above half up and at most 1 below. Black and
  white half and half: #7F7F7F truncated, #808080 half up.
- Scaling brightness by f percent and back by 100/f: by 100% or more,
  every channel that was not clamped comes back exactly; a clamped one is
  lost. Scaled down, a channel can come back 1 off.
- A gradient of n colours weights the two ends (n - 1 - k) : k.
- Easing moves each channel a percentage of the way that is left. It
  stops short when every step left rounds to 0: truncated, when the gap
  times the percentage is below 100, and half up, below 50. Black to white
  at 20% stops at #FBFBFB truncated and #FDFDFD half up.

What is checked: menu choices 1 to 7; colours as #RRGGBB or RRGGBB in
either case, or a palette name; a weight from 0 to 100, a brightness from
1 to 400, 2 to 12 colours in a gradient, and an easing step from 1 to 99.
An empty answer cancels.

Sessions: `sessions/basic.in` gives:
- the palette;
- black and white half and half, and 30% teal into rust;
- #C8B4C8 + #3C3C3C (red and blue clamped) and white + red (all three);
- rust times 200% and back (red lost), and #C9652F times 50% and back;
- black to white in 5 colours;
- easing black to white at 20%, and rust to #14A096 at 10%.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- colours: a cancel, pink, #12345G and #1234, then black with a cancel;
- weights: a cancel, 101 and x; brightness 0 and 401; gradients of 1
  and 13; easing steps of 0 and 100; easing grey to grey.

Built on the verified corpus case `rgb-palette-blender` (colours added and
scaled one channel at a time, each clamped on its own).
