# Editor tools

> Action buttons that orbit the selection, a brush cursor with live resize and pen pressure, a hold-to-show mode ring and a sliding segmented switch.

Source: https://pixi-silk.schmooky.dev/docs/editor-tools/

> **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 `halo` (source):

```ts
import { Container } from 'pixi.js';
import { SilkGraphics } from 'pixi-silk';
import { trackGesture } from 'pixi-silk/ui/gesture';
import { ContextHalo } from 'pixi-silk/ui/halo';
import { defineDemo } from './runtime';

// Icons drawn in white around (0, 0). The halo tints them.
const copy = (g: SilkGraphics, s: number) => {
    g.roundRect(-s * 0.4, -s * 0.2, s * 0.55, s * 0.6, 3).stroke({ width: s * 0.1, color: 0xffffff });
    g.roundRect(-s * 0.15, -s * 0.45, s * 0.55, s * 0.6, 3).fill(0xffffff);
};
const trash = (g: SilkGraphics, s: number) => {
    g.roundRect(-s * 0.3, -s * 0.22, s * 0.6, s * 0.66, 3).fill(0xffffff);
    g.line(-s * 0.42, -s * 0.34, s * 0.42, -s * 0.34).stroke({ width: s * 0.1, cap: 'round', color: 0xffffff });
};
const lock = (g: SilkGraphics, s: number) => {
    g.roundRect(-s * 0.32, -s * 0.05, s * 0.64, s * 0.46, 4).fill(0xffffff);
    g.arcSweep(0, -s * 0.08, s * 0.2, Math.PI, Math.PI).stroke({ width: s * 0.1, color: 0xffffff });
};
const front = (g: SilkGraphics, s: number) => {
    g.regularPoly(0, -s * 0.12, s * 0.3, 3, 0, 2).fill(0xffffff);
    g.line(-s * 0.3, s * 0.32, s * 0.3, s * 0.32).stroke({ width: s * 0.1, cap: 'round', color: 0xffffff });
};
const paint = (g: SilkGraphics, s: number) => g.circle(0, 0, s * 0.36).fill(0xffffff);

// Click a shape to select it. Drag it around: the halo follows and turns away from the edges.
export default defineDemo({
    title: 'Selection halo',
    size: [640, 380],
    touchAction: 'none',
    controls: {
        hideWhileDragging: { type: 'toggle', label: 'hide while dragging', value: false },
    },
    setup({ silk, stage, params, tick }) {
        const world = new Container();
        const halo = new ContextHalo({
            items: [
                { id: 'copy', label: 'Duplicate', icon: copy },
                { id: 'paint', label: 'Colour', icon: paint },
                { id: 'front', label: 'Bring to front', icon: front },
                { id: 'lock', label: 'Lock', icon: lock, disabled: true },
                { id: 'delete', label: 'Delete', icon: trash },
            ],
            bounds: { x: 0, y: 0, width: 640, height: 380 },
            ticker: silk.app.ticker,
            onSelect: (item) => {
                const target = halo.state.target.value as SilkGraphics | null;

                if (!target) return;
                if (item.id === 'delete') {
                    halo.detach();
                    target.destroy();
                } else if (item.id === 'front') world.addChild(target);
                else if (item.id === 'paint') redraw(target, (Math.random() * 0xffffff) | 0);
            },
        });
        const redraw = (g: SilkGraphics, colour: number) => {
            g.clear().roundRect(-60, -40, 120, 80, 18, 0.6).fill(colour);
        };
        const backdrop = new Container();

        backdrop.eventMode = 'static';
        backdrop.hitArea = { contains: () => true };
        backdrop.on('pointerdown', () => halo.detach());
        stage.addChild(backdrop, world, halo);

        [
            [170, 190, 0x0a84ff],
            [420, 110, 0x30d158],
            [520, 300, 0xff9f0a],
        ].forEach(([x, y, colour]) => {
            const g = new SilkGraphics();
            let grab = { x: 0, y: 0 };

            redraw(g, colour);
            g.position.set(x, y);
            g.cursor = 'grab';
            world.addChild(g);
            trackGesture(g, {
                onDown: (p) => {
                    halo.attach(g);
                    grab = { x: p.x - g.x, y: p.y - g.y };
                },
                onDragStart: () => halo.state.dragging.set(true),
                onDrag: (p) => g.position.set(p.x - grab.x, p.y - grab.y),
                onDragEnd: () => halo.state.dragging.set(false),
            });
        });
        halo.attach(world.children[0]);

        tick(() => {
            halo.hideWhileDragging = params.hideWhileDragging;
        });

        return undefined;
    },
});
```

Four controls for the tool layer of an editor. `ContextHalo` is in `pixi-silk/ui/halo`,
`BrushCursor` in `pixi-silk/ui/brush`, and `ModeSwitcher` and `SegmentedSwitch` in
`pixi-silk/ui/modes`.

## Show actions around the selection

```ts

const halo = new ContextHalo({
    items: [
        { id: 'copy', label: 'Duplicate', icon: drawMove },
        { id: 'delete', label: 'Delete', icon: drawTrash },
    ],
    bounds: { x: 0, y: 0, width: app.screen.width, height: app.screen.height },
    onSelect: (item) => console.log(item.id, halo.state.target.value),
});

app.stage.addChild(halo);
halo.attach(stage.children[0]);
```

Round buttons sit on an arc around the target's box and follow its bounds every frame, so drags,
tweens and camera moves need no wiring. Near an edge of `bounds` the arc turns to the side with
room. Attaching another object pops the buttons out at the old one and back in around the new
one. Input is off while they pop.

Set `state.dragging` while the reader drags the target. The halo then follows faster, or folds
away with `hideWhileDragging: true`.

| Option | Default | What it does |
|---|---|---|
| `radius` | `18` | Radius of each button. |
| `gap` | `10` | Distance from the target's box to the buttons. |
| `direction` | above | Preferred side, as an angle in radians. |
| `bounds` | none | The rectangle the buttons stay inside. |
| `transition` | `'pop'` | `'glide'` slides the buttons to a new target instead. |

An item with `disabled: true` dims and ignores taps. Its `label` shows in a pill on hover.

## Show the brush under the pointer

Live demo `brush` (source):

```ts
import { Container, RenderTexture, Sprite } from 'pixi.js';
import { SilkGraphics } from 'pixi-silk';
import { BrushCursor } from 'pixi-silk/ui/brush';
import { defineDemo, SERIES } from './runtime';

// Paint on the canvas. Hold F and move (or drag with the right button) to resize: sideways is
// size, up and down is hardness. [ and ] step the size. A pen's pressure shows as the inner disc.
// Each stroke takes the next colour of the palette.
export default defineDemo({
    title: 'Brush cursor',
    size: [720, 360],
    touchAction: 'none',
    setup({ silk, stage, width, height }) {
        const surface = new Container();
        const canvas = RenderTexture.create({ width, height, resolution: silk.resolution });
        const dab = new SilkGraphics();
        const brush = new BrushCursor({ size: 36, hardness: 0.6, surface, ticker: silk.app.ticker });
        let painting = false;
        let last: { x: number; y: number } | null = null;
        let colour = 0;

        surface.eventMode = 'static';
        surface.hitArea = { contains: (x, y) => x >= 0 && y >= 0 && x <= width && y <= height };
        const paper = new Sprite(canvas);

        stage.addChild(paper, surface, brush);

        const stamp = (x: number, y: number, pressure: number) => {
            const { size, hardness, opacity } = brush.state;
            const r = (size.value / 2) * (0.35 + 0.65 * pressure);

            // soft brushes are a blurred dab, hard ones a crisp disc
            dab.clear()
                .circle(x, y, r * (0.6 + 0.4 * hardness.value))
                .fill({
                    color: SERIES[colour % SERIES.length],
                    alpha: 0.35 * opacity.value,
                    blur: r * (1 - hardness.value) * 0.6,
                });
            silk.app.renderer.render({ container: dab, target: canvas, clear: false });
        };
        const paintTo = (x: number, y: number, pressure: number) => {
            const from = last ?? { x, y };
            const step = Math.max(1, brush.state.size.value * 0.12);
            const n = Math.max(1, Math.ceil(Math.hypot(x - from.x, y - from.y) / step));

            for (let i = 1; i <= n; i++)
                stamp(from.x + ((x - from.x) * i) / n, from.y + ((y - from.y) * i) / n, pressure);
            last = { x, y };
        };

        // a few strokes to start from: a hard one, a soft one and a thin one
        const strokes: [size: number, hardness: number, path: (u: number) => [number, number]][] = [
            [40, 0.85, (u) => [80 + u * 560, 120 + Math.sin(u * Math.PI * 2) * 50]],
            [64, 0.15, (u) => [100 + u * 520, 230 + Math.sin(u * Math.PI * 2 + 2) * 40]],
            [14, 1, (u) => [140 + u * 440, 290 - Math.sin(u * Math.PI) * 30]],
        ];

        for (const [size, hardness, path] of strokes) {
            brush.state.size.set(size);
            brush.state.hardness.set(hardness);
            last = null;
            for (let i = 0; i <= 60; i++) paintTo(...path(i / 60), 0.4 + 0.6 * Math.sin((i / 60) * Math.PI));
            colour++;
        }
        brush.state.size.set(36);
        brush.state.hardness.set(0.6);
        last = null;

        surface.on('pointerdown', (e) => {
            if (e.button !== 0 || brush.state.resizing.value) return;
            painting = true;
            last = null;
            const p = stage.toLocal(e.global);

            paintTo(p.x, p.y, e.pointerType === 'pen' ? e.pressure : 0.6);
        });
        surface.on('globalpointermove', (e) => {
            if (!painting || brush.state.resizing.value) return;
            const p = stage.toLocal(e.global);

            paintTo(p.x, p.y, e.pointerType === 'pen' ? e.pressure : 0.6);
        });
        const stop = () => {
            if (painting) colour++;
            painting = false;
            last = null;
        };

        surface.on('pointerup', stop);
        surface.on('pointerupoutside', stop);

        return () => {
            // take the sprite off the stage first, then free the texture once the GPU is done with it
            paper.destroy();
            requestAnimationFrame(() => canvas.destroy(true));
        };
    },
});
```

```ts

const brush = new BrushCursor({ size: 24, hardness: 0.8, surface: stage });

app.stage.addChild(brush);
brush.state.size.subscribe((size) => console.log('brush size', size));
```

The ring is the brush size. A dashed inner ring shows hardness, and a disc grows with pen
pressure. Small brushes get a crosshair so the hot spot stays visible. The OS cursor is hidden
over `surface`. The cursor draws the brush and does not paint: read `state` and paint with your
own code.

| Option | Default | What it does |
|---|---|---|
| `size` | `32` | Brush diameter. Clamped to `minSize` (1) and `maxSize` (500). |
| `hardness` | `0.7` | 0 is soft, 1 is hard. |
| `resizeKey` | `'KeyF'` | Hold it and move to resize. The ring stays where the resize began. `null` turns it off. |
| `rightDragResize` | `true` | A right-button drag resizes too. |
| `bracketKeys` | `true` | `[` and `]` step the size. |

`state.pressure` reads pen pressure from 0 to 1. A mouse reports 0.5 while a button is down.

## Switch modes with a held key

Live demo `modes` (source):

```ts
import { Text } from 'pixi.js';
import type { SilkGraphics } from 'pixi-silk';
import { ModeSwitcher, SegmentedSwitch } from 'pixi-silk/ui/modes';
import { defineDemo } from './runtime';

type Icon = (g: SilkGraphics, s: number) => void;
const brush: Icon = (g, s) => {
    g.line(-s * 0.3, s * 0.3, s * 0.3, -s * 0.3).stroke({ width: s * 0.14, cap: 'round', color: 0xffffff });
    g.circle(-s * 0.32, s * 0.32, s * 0.14).fill(0xffffff);
};
const eraser: Icon = (g, s) => g.roundRect(-s * 0.36, -s * 0.2, s * 0.72, s * 0.4, 4).fill(0xffffff);
const smudge: Icon = (g, s) => g.heart(0, 0, s * 0.7, 0.2).fill(0xffffff);
const fill: Icon = (g, s) => g.regularPoly(0, 0, s * 0.38, 6, 0, 3).fill(0xffffff);
const pick: Icon = (g, s) => {
    g.circle(0, 0, s * 0.3).stroke({ width: s * 0.1, color: 0xffffff });
    g.circle(0, 0, s * 0.08).fill(0xffffff);
};

// Hold Space over the canvas (or long-press it) for the mode ring, release on a mode.
// Tap Space to swap back to the previous mode. The pill below is a SegmentedSwitch.
export default defineDemo({
    title: 'Mode ring',
    size: [640, 360],
    // the layout is centred on the width, so phones only need a narrower design size
    phone: { size: [360, 360] },
    touchAction: 'none',
    setup({ silk, stage, width, height }) {
        const ticker = silk.app.ticker;
        const status = new Text({
            text: '',
            style: { fill: 0xf2f2f7, fontFamily: 'system-ui, sans-serif', fontSize: 18, fontWeight: '600' },
        });
        const hint = new Text({
            text: 'hold Space or long-press, tap Space to swap back',
            style: { fill: 0x8e8e93, fontFamily: 'system-ui, sans-serif', fontSize: 13 },
        });
        const speed = new SegmentedSwitch({
            options: [
                { id: 'slow', label: 'Slow' },
                { id: 'normal', label: 'Normal' },
                { id: 'turbo', label: 'Turbo' },
            ],
            value: 'normal',
            ticker,
        });
        const tools = new SegmentedSwitch({
            options: [
                { id: 'brush', icon: brush },
                { id: 'eraser', icon: eraser },
                { id: 'smudge', icon: smudge },
                { id: 'fill', icon: fill },
                { id: 'pick', icon: pick },
            ],
            segmentWidth: 48,
            ticker,
        });
        const modes = new ModeSwitcher({
            modes: [
                { id: 'brush', label: 'BRUSH', icon: brush },
                { id: 'eraser', label: 'ERASER', icon: eraser },
                { id: 'smudge', label: 'SMUDGE', icon: smudge },
                { id: 'fill', label: 'FILL', icon: fill },
                { id: 'pick', label: 'PICKER', icon: pick },
            ],
            longPressTarget: stage,
            ticker,
        });

        stage.eventMode = 'static';
        stage.hitArea = { contains: (x, y) => x >= 0 && y >= 0 && x <= width && y <= height };
        status.anchor.set(0.5);
        hint.anchor.set(0.5);
        status.position.set(width / 2, 70);
        hint.position.set(width / 2, 100);
        tools.position.set(width / 2, 190);
        speed.position.set(width / 2, 290);
        stage.addChild(status, hint, tools, speed, modes);

        // the switcher and the toolbar share one source of truth
        const show = (mode: string) => {
            status.text = `mode: ${mode}`;
            tools.state.value.set(mode);
        };

        modes.state.mode.subscribe(show);
        tools.state.value.subscribe((id) => modes.set(id));
        show(modes.mode);

        return undefined;
    },
});
```

```ts

const modes = new ModeSwitcher({
    modes: [
        { id: 'brush', label: 'BRUSH', icon: drawPointer },
        { id: 'eraser', label: 'ERASER', icon: drawTrash },
    ],
    longPressTarget: app.stage,
    onChange: (mode, previous) => console.log(previous, mode),
});

app.stage.addChild(modes);
```

Hold `key` (Space by default) for 160 ms and a ring of modes opens under the pointer, most
recently used first. Release over a mode to switch. A quick tap swaps back to the previous mode
without showing anything. On touch screens a long-press on `longPressTarget` opens the same ring.

The key only acts while the pointer is over the canvas and no text field has focus. The ring is a
[radial menu](/docs/radial-menus-and-buttons/), styled through the `menu` option.

## Pick one of a few options

```ts

const speed = new SegmentedSwitch({
    options: [
        { id: 'slow', label: 'Slow' },
        { id: 'normal', label: 'Normal' },
        { id: 'turbo', label: 'Turbo' },
    ],
    value: 'normal',
});

speed.state.value.subscribe((id) => console.log(id));
```

A pill of choices with a thumb that slides to the selected one. Tap a segment, or press and slide
along the pill: the thumb follows the finger and the choice lands on release. Segments are at
least `segmentWidth` (64) wide and grow to fit their labels or icons.
