# Colour pickers

> Hue rings, a colour wheel, an OKLCH plane, channel sliders, swatches, a radial swatch popup and an eyedropper for PixiJS, all editing one colour model.

Source: https://pixi-silk.schmooky.dev/docs/color-pickers/

> **NOTE**
> Experimental in 1.0: the API may change in a minor release.

Demo sources run in the docs demo runner, `defineDemo({ setup({ stage, params, tick }) })`. In your own app, `stage` is `app.stage` (or a container on it), `params` holds the values of the demo controls, and `tick(fn)` is `app.ticker.add`, with `t` and `dt` in seconds.

Live demo `color-rings` (source):

```ts
import { SilkGraphics } from 'pixi-silk';
import { ColorModel, ColorWheel, HueSquarePicker, HueTrianglePicker, SwatchPalette } from 'pixi-silk/ui/color';
import { defineDemo } from './runtime';

// Three pickers bound to one model: drag any of them and the others follow. Releasing a drag
// commits the colour into the recent row at the bottom.
export default defineDemo({
    title: 'Colour rings',
    size: [740, 330],
    touchAction: 'none',
    // phones: triangle and square side by side under the Studio link, the wheel under them
    phone: { size: [360, 490] },
    setup({ silk, stage, tick, phone }) {
        const ticker = silk.app.ticker;
        const model = new ColorModel({ color: 0x30d158 });
        const triangle = new HueTrianglePicker({ model, size: phone ? 160 : 220, ticker });
        const square = new HueSquarePicker({ model, size: phone ? 160 : 220, ticker });
        const wheel = new ColorWheel({ model, size: phone ? 200 : 196, ticker });
        const recent = new SwatchPalette({ model, columns: phone ? 8 : 12, size: 26, gap: 8, ticker });
        const chip = new SilkGraphics();

        if (phone) {
            triangle.position.set(14, 40);
            square.position.set(186, 40);
            wheel.position.set(80, 218);
            recent.position.set(60, 442);
            chip.position.set(16, 442);
        } else {
            triangle.position.set(20, 16);
            square.position.set(262, 16);
            wheel.position.set(506, 28);
            recent.position.set(64, 278);
            chip.position.set(20, 278);
        }
        stage.addChild(triangle, square, wheel, recent, chip);
        for (const c of [0xff453a, 0xffd60a, 0x0a84ff]) {
            model.setHex(c);
            model.commit();
        }
        model.setHex(0x30d158);

        tick(() => {
            chip.clear().roundRect(0, 0, 32, 26, 8).fill(model.hex);
        });

        return undefined;
    },
});
```

Every picker in `pixi-silk/ui/color` edits a `ColorModel`. Pickers that share a model stay in sync.

## Share one colour between pickers

```ts

declare const brush: { tint: number };
declare function save(color: number): void;

const model = new ColorModel({ color: 0x30d158 });
const triangle = new HueTrianglePicker({ model, size: 220 });
const wheel = new ColorWheel({ model, size: 196 });
const recent = new SwatchPalette({ model });

model.value.subscribe(() => (brush.tint = model.hex));
model.committed.subscribe(() => save(model.hex));
```

`model.value` changes on every move of a drag, so the brush follows live. When a drag ends, the
picker calls `commit()`. That adds the colour to `model.history` and bumps `committed`, so `save`
runs once per release. A `SwatchPalette` with no `colors` shows that history.

The model stores hue, saturation, value and alpha. The hue therefore survives greys and black,
where RGB would lose it. `hex`, `rgb` and `oklch` read the colour, and `setHex()`, `setRgb()`,
`setHsv()` and `setOklch()` write it.

| Option | Default | What it does |
|---|---|---|
| `color` | `0x0a84ff` | Starting colour. |
| `alpha` | `1` | Starting alpha. |
| `historySize` | `16` | How many committed colours `history` keeps. |

## Pick a picker

| Class | What it shows |
|---|---|
| `HueTrianglePicker` | A hue ring around a triangle with the pure hue, white and black at its corners. The triangle turns to face its hue. |
| `HueSquarePicker` | A hue ring around a saturation and value square. |
| `ColorWheel` | Hue around a disc, saturation from the centre out, value on a strip beside it. |
| `OklchPicker` | Lightness against chroma for one OKLCH hue, plus a hue strip. |
| `ColorSlider`, `ChannelSliders` | Gradient tracks for HSV, RGB, OKLCH and alpha. Each track shows what its positions would give with the rest of the colour unchanged. |
| `SwatchPalette` | Fixed colours or the recent ones, as a grid or a ring. |
| `SwatchPopup` | A ring of swatches that opens around the pointer. |
| `Eyedropper` | Samples the scene through a function you supply. |

A drag belongs to the part it started on. A drag that starts on the ring only turns the hue, even
when it crosses the square.

## Pick in OKLCH

Live demo `color-oklch` (source):

```ts
import { SilkGraphics } from 'pixi-silk';
import { ChannelSliders, ColorModel, checker, OklchPicker } from 'pixi-silk/ui/color';
import { defineDemo } from './runtime';

// Lightness against chroma for one OKLCH hue. The grey area is outside sRGB; dragging into it
// slides along the edge. The sliders edit the same colour in the chosen space.
export default defineDemo({
    title: 'OKLCH picker',
    size: [600, 260],
    touchAction: 'none',
    // phones: the plane above the sliders
    phone: { size: [360, 430] },
    controls: {
        sliders: { type: 'select', label: 'sliders', options: ['oklch', 'hsv', 'rgb'], value: 'oklch' },
    },
    setup({ silk, stage, params, tick, phone }) {
        const ticker = silk.app.ticker;
        const model = new ColorModel({ color: 0xbf5af2 });
        const plane = new OklchPicker({ model, width: phone ? 320 : 260, height: phone ? 170 : 180, ticker });
        // top left corner of the sliders, and how far below them the preview sits
        const side: [number, number] = phone ? [20, 244] : [330, 24];
        const below = phone ? 130 : 172;
        const preview = new SilkGraphics();
        let mode = '';
        let sliders: ChannelSliders | null = null;

        plane.position.set(20, 20);
        preview.position.set(side[0], side[1] + below);
        stage.addChild(plane, preview);

        tick(() => {
            if (params.sliders !== mode) {
                mode = params.sliders;
                sliders?.destroy();
                sliders = new ChannelSliders({
                    model,
                    mode: params.sliders as 'oklch',
                    width: phone ? 298 : 220,
                    ticker,
                });
                sliders.position.set(side[0], side[1]);
                stage.addChild(sliders);
            }
            preview.clear();
            checker(preview, 22, 0, phone ? 296 : 220, 36, 10, 9);
            preview.roundRect(22, 0, phone ? 296 : 220, 36, 10).fill({ color: model.hex, alpha: model.alpha });
        });

        return undefined;
    },
});
```

```ts

const model = new ColorModel();
const plane = new OklchPicker({ model, width: 260, height: 180 });
const sliders = new ChannelSliders({ model, mode: 'oklch' });
```

OKLCH is lightness, chroma and hue in a space built to match how colours look. Moving lightness
does not shift the hue, and equal steps look equal. Not every OKLCH colour fits on a screen. The
picker greys out the part outside sRGB and draws its edge, and a drag into the grey part slides
along that edge. `ChannelSliders` also takes `mode: 'hsv'` or `'rgb'`, with an alpha track by
default.

## Pick from swatches or the scene

Live demo `color-popup` (source):

```ts
import { Container } from 'pixi.js';
import { horizontal, SilkGraphics, vertical } from 'pixi-silk';
import { SilkButton } from 'pixi-silk/ui/button';
import { ColorModel, Eyedropper, hsvToRgb, rgbToHex, SwatchPalette, SwatchPopup } from 'pixi-silk/ui/color';
import { defineDemo } from './runtime';

// Press anywhere on the artwork for the swatch ring: drag to a colour and let go, or tap to keep
// it open. The pipette samples the artwork (the app supplies the sampler, here plain maths).
const W = 600;
const H = 260;
/** The artwork rectangle in design space. */
type Rect = { x: number; y: number; w: number; h: number };
/** Colour of the artwork at a design-space point: hue across, value down. */
const artColor = (ART: Rect, x: number, y: number): number | null => {
    const u = (x - ART.x) / ART.w;
    const v = (y - ART.y) / ART.h;

    return u < 0 || u > 1 || v < 0 || v > 1 ? null : rgbToHex(hsvToRgb(u * 360, 0.85, 1 - v * 0.8));
};
const pipette = (g: SilkGraphics, s: number) => {
    g.line(-s * 0.32, s * 0.32, s * 0.12, -s * 0.12).stroke({ width: s * 0.16, cap: 'round', color: 0xffffff });
    g.circle(s * 0.22, -s * 0.22, s * 0.16).fill(0xffffff);
};

export default defineDemo({
    title: 'Colour popup',
    size: [W, H],
    touchAction: 'none',
    // phones: the artwork under the Studio link, the colour, pipette and recent row under it
    phone: { size: [360, 350] },
    setup({ silk, stage, phone }) {
        const ART: Rect = phone ? { x: 20, y: 48, w: 320, h: 214 } : { x: 20, y: 20, w: 420, h: 220 };
        const ticker = silk.app.ticker;
        const model = new ColorModel({ color: 0xff9f0a });
        const art = new SilkGraphics();
        const surface = new Container();
        const popup = new SwatchPopup({
            model,
            colors: [0xff453a, 0xff9f0a, 0xffd60a, 0x30d158, 0x64d2ff, 0x0a84ff, 0xbf5af2, 0xffffff],
            ticker,
        });
        const chip = new SilkGraphics();
        const recent = new SwatchPalette({ model, columns: 4, size: 26, gap: 8, ticker });
        const dropper = new Eyedropper({
            model,
            ticker,
            // global canvas point -> design space of the demo
            sample: (gx, gy) => {
                const p = stage.toLocal({ x: gx, y: gy });

                return artColor(ART, p.x, p.y);
            },
        });
        const button = new SilkButton({
            shape: 'circle',
            height: 44,
            icon: pipette,
            ticker,
            theme: { fill: 0x2c2c2e, fillHover: 0x3a3a3c, fillPress: 0x1c1c1e },
            onPress: () => dropper.start(),
        });

        art.roundRect(ART.x, ART.y, ART.w, ART.h, 14).fill({
            gradient: horizontal([0xff0000, 0xffff00, 0x00ff00, 0x00ffff, 0x0000ff, 0xff00ff, 0xff0000], {
                space: 'srgb',
            }),
        });
        art.roundRect(ART.x, ART.y, ART.w, ART.h, 14).fill({ color: 0xffffff, alpha: 0.15 });
        art.roundRect(ART.x, ART.y, ART.w, ART.h, 14).fill({
            gradient: vertical(
                [
                    [0, 0x000000, 0],
                    [1, 0x000000, 0.8],
                ],
                { space: 'srgb' },
            ),
        });
        surface.eventMode = 'static';
        surface.hitArea = { contains: (x, y) => x >= ART.x && y >= ART.y && x <= ART.x + ART.w && y <= ART.y + ART.h };
        surface.on('pointerdown', (e) => {
            const p = stage.toLocal(e.global);

            popup.openAt(p.x, p.y, e);
        });
        if (phone) {
            chip.position.set(20, 282);
            button.position.set(112, 304);
            recent.position.set(152, 291);
        } else {
            button.position.set(482, 42);
            chip.position.set(520, 20);
            recent.position.set(462, 96);
        }
        stage.addChild(art, surface, chip, recent, button, popup, dropper);
        model.value.subscribe(() => chip.clear().roundRect(0, 0, 60, 44, 12).fill(model.hex));
        chip.roundRect(0, 0, 60, 44, 12).fill(model.hex);

        return undefined;
    },
});
```

```ts

declare const artwork: Container, pipette: Container;
declare function colorAt(x: number, y: number): number | null;

const model = new ColorModel();
const popup = new SwatchPopup({ model, colors: [0xff453a, 0xffd60a, 0x30d158, 0x0a84ff] });
const dropper = new Eyedropper({ model, sample: (x, y) => colorAt(x, y) });

artwork.on('pointerdown', (e) => popup.openAt(e.global.x, e.global.y, e));
pipette.on('pointertap', () => dropper.start());
```

The swatch popup works like the [radial menu](/docs/radial-menus-and-buttons/): press, drag
towards a colour and let go, or tap to keep it open.

The eyedropper does not read pixels itself, because your app knows its scene better. The sampler
can be a texture lookup, `renderer.extract` on a render target or plain maths. A click picks,
and Escape or a right click cancels.

## How the colours are drawn

The pickers draw with pixi-silk [gradients](/docs/gradients/). Hue rings are conic gradients
interpolated in sRGB, so every angle is an exact HSV hue. The square and the wheel layer white and
black ramps. The OKLCH plane is drawn row by row, and chroma along a row is a straight line in
OKLab, so each row is exact.
