# Radial menus and buttons

> RadialMenu is a marking menu with nested rings and per-device tuning. SilkButton animates in and out and ignores input while it moves or is busy.

Source: https://pixi-silk.schmooky.dev/docs/radial-menus-and-buttons/

A radial menu puts its items in a ring around the pointer, so a press, a drag towards an item and
a release pick it in one gesture. A button here is a widget that knows when it should not take a press.

## Open a radial menu

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 `radial-menu` (source):

```ts
import { Container, Text } from 'pixi.js';
import { SilkGraphics } from 'pixi-silk';
import { SilkButton } from 'pixi-silk/ui/button';
import { bindRadialTrigger, type RadialItem, RadialMenu } from 'pixi-silk/ui/radial';
import { defineDemo, FONT, INK, label, PALETTE } from './runtime';

// Vector icons: drawn once in white around (0, 0); the menu tints them.
type Icon = (g: SilkGraphics, s: number) => void;
const pointer: Icon = (g, s) => {
    g.triangle(-s * 0.3, -s * 0.42, s * 0.34, s * 0.06, -s * 0.06, s * 0.16, 2).fill(0xffffff);
    g.line(-s * 0.02, s * 0.12, s * 0.16, s * 0.42).stroke({ width: s * 0.12, cap: 'round', color: 0xffffff });
};
const move: Icon = (g, s) => {
    const r = s * 0.42;

    g.line(-r, 0, r, 0).stroke({ width: s * 0.09, cap: 'round', color: 0xffffff });
    g.line(0, -r, 0, r).stroke({ width: s * 0.09, cap: 'round', color: 0xffffff });
    for (let i = 0; i < 4; i++) {
        const a = (i * Math.PI) / 2;

        g.regularPoly(Math.cos(a) * r, Math.sin(a) * r, s * 0.14, 3, a + Math.PI / 2, 1).fill(0xffffff);
    }
};
const rotate: Icon = (g, s) => {
    g.arcSweep(0, 0, s * 0.32, -Math.PI * 0.35, Math.PI * 1.6).stroke({
        width: s * 0.1,
        cap: 'round',
        color: 0xffffff,
    });
    g.regularPoly(s * 0.18, -s * 0.3, s * 0.14, 3, Math.PI / 2, 1).fill(0xffffff);
};
const scale: Icon = (g, s) => {
    g.roundRect(-s * 0.4, -s * 0.1, s * 0.5, s * 0.5, 3).stroke({ width: s * 0.08, color: 0xffffff });
    g.roundRect(-s * 0.1, -s * 0.42, s * 0.52, s * 0.52, 3).fill(0xffffff);
};
const gear: Icon = (g, s) => {
    g.circle(0, 0, s * 0.33).stroke({ width: s * 0.16, dash: [s * 0.13, s * 0.13], color: 0xffffff });
    g.circle(0, 0, s * 0.22).stroke({ width: s * 0.1, color: 0xffffff });
};
const more: Icon = (g, s) => {
    for (const x of [-1, 0, 1]) g.circle(x * s * 0.28, 0, s * 0.09).fill(0xffffff);
};
const heart: Icon = (g, s) => g.heart(0, 0, s * 0.8, 0.1).fill(0xffffff);
const star: Icon = (g, s) => g.star(0, 0, 5, s * 0.45, undefined, 0, 1.5).fill(0xffffff);
const plus: Icon = (g, s) => {
    g.line(-s * 0.35, 0, s * 0.35, 0).stroke({ width: s * 0.13, cap: 'round', color: 0xffffff });
    g.line(0, -s * 0.35, 0, s * 0.35).stroke({ width: s * 0.13, cap: 'round', color: 0xffffff });
};

const tools: RadialItem[] = [
    { id: 'select', label: 'SELECT', icon: pointer },
    {
        id: 'move',
        label: 'MOVE',
        icon: move,
        children: [
            { id: 'intuitive', label: 'Intuitive' },
            { id: 'axis', label: 'Axis' },
            { id: 'free', label: 'Free' },
        ],
    },
    {
        id: 'rotate',
        label: 'ROTATE',
        icon: rotate,
        children: [
            { id: 'uniform', label: 'Uniform' },
            { id: 'axis', label: 'Axis' },
            { id: 'free', label: 'Free' },
        ],
    },
    { id: 'scale', label: 'SCALE', icon: scale },
    { id: 'settings', label: 'SETTINGS', icon: gear, disabled: true },
    { id: 'more', label: 'MORE', icon: more },
];

// Press anywhere (or the round button) and drag to an item, or tap to keep the menu open.
// The `+` button opens a half ring that turns away from the edge it sits on. The artboard
// on the right is only scenery: the menus pick tools, they do not edit it.
export default defineDemo({
    title: 'Radial menu',
    size: [720, 400],
    // phones: the artboard goes under the round button
    phone: { size: [360, 600] },
    touchAction: 'none',
    controls: {
        closeOnLeave: { type: 'toggle', label: 'close on leave', value: false },
    },
    setup({ silk, stage, params, tick, width, height, phone }) {
        const ticker = silk.app.ticker;
        const surface = new Container();
        const status = new Text({
            text: 'press, drag, release, or tap',
            style: { fill: INK.muted, fontFamily: FONT, fontSize: 13 },
        });
        const bounds = { x: 0, y: 0, width, height };
        const report = (_item: RadialItem, path: readonly RadialItem[]) => {
            status.text = `picked  ${path.map((i) => i.id).join(' › ')}`;
        };
        const menu = new RadialMenu({ items: tools, bounds, ticker, onSelect: report });
        const quick = new RadialMenu({
            items: [
                { id: 'heart', label: 'LIKE', icon: heart },
                { id: 'star', label: 'FAVOURITE', icon: star },
                { id: 'plus', label: 'ADD', icon: plus },
                { id: 'more', label: 'MORE', icon: more },
            ],
            sweep: Math.PI * 0.9,
            innerRadius: 34,
            outerRadius: 92,
            bounds,
            ticker,
            onSelect: report,
        });
        const hub = new SilkButton({ shape: 'circle', height: 64, icon: gear, iconSize: 28, ticker });
        const add = new SilkButton({ shape: 'circle', height: 52, icon: plus, iconSize: 22, ticker });

        // scenery: an artboard with a few layers, as in a drawing app
        const board = new SilkGraphics()
            .roundRect(330, 70, 300, 250, 16, 0.6)
            .fill(INK.surface)
            .stroke({ width: 1, color: INK.line, alignment: 'inside' })
            .roundRect(360, 104, 120, 84, 14, 0.6)
            .fill(PALETTE.blue)
            .circle(550, 150, 44)
            .fill(PALETTE.pink)
            .star(420, 258, 5, 40, undefined, 0, 4)
            .fill(PALETTE.yellow)
            .roundRect(500, 224, 100, 64, 32)
            .fill(PALETTE.green);

        surface.eventMode = 'static';
        surface.hitArea = { contains: (x, y) => x >= 0 && y >= 0 && x <= width && y <= height };
        // phones: the hub on top, the board drawn at (330, 70) moved under it
        const hubAt = phone ? [180, 170] : [170, 200];
        const addAt = phone ? [312, 556] : [670, 350];
        const boardAt = phone ? [30, 300] : [330, 70];

        board.position.set(boardAt[0] - 330, boardAt[1] - 70);
        status.position.set(phone ? 16 : 24, 20);
        hub.position.set(hubAt[0], hubAt[1]);
        add.position.set(addAt[0], addAt[1]);
        stage.addChild(
            surface,
            board,
            label('Artboard', boardAt[0], boardAt[1] - 24),
            label('tools', hubAt[0], hubAt[1] + 44, {}, 0.5),
            label('quick add', addAt[0] - 36, addAt[1] - 8, {}, 1),
            status,
            hub,
            add,
            menu,
            quick,
        );

        bindRadialTrigger(surface, menu, { at: 'pointer' });
        bindRadialTrigger(hub, menu);
        bindRadialTrigger(add, quick);
        menu.state.hover.subscribe((item) => {
            if (item) status.text = `${menu.state.kind.value}: ${item.id}${item.disabled ? ' (disabled)' : ''}`;
        });

        tick(() => {
            menu.closeOnLeave = quick.closeOnLeave = params.closeOnLeave;
        });

        return undefined;
    },
});
```

```ts

const menu = new RadialMenu({
    items: [
        { id: 'select', label: 'SELECT', icon: drawPointer },
        { id: 'move', label: 'MOVE', icon: drawMove, children: [{ id: 'axis', label: 'Axis' }, { id: 'free', label: 'Free' }] },
        { id: 'delete', label: 'DELETE', icon: drawTrash, disabled: true },
    ],
    sweep: Math.PI * 2, // Math.PI for a half ring
    bounds: { x: 0, y: 0, width: app.screen.width, height: app.screen.height },
    onSelect: (item, path) => console.log(path.map((i) => i.id).join(' > ')),
});

app.stage.addChild(menu);
bindRadialTrigger(toolButton, menu); // or menu.openAt(x, y, event)
```

A press on `toolButton` opens the menu at the pointer. `onSelect` gets the item and the path to it
through the rings, so a pick in the second ring logs `move > axis`.

The menu picks by angle. Past a small dead zone the whole wedge counts, out to the falloff
distance, so a fast flick lands on its item even when the pointer overshoots the ring. The current
item holds until the pointer is clearly past its edge, so a shaky finger on a boundary does not
flicker between two items.

Items with `children` open the next ring around themselves, to any depth. A full ring slides to
stay inside `bounds`, and a partial ring turns to face the inside instead.

| Option | Default | What it does |
| --- | --- | --- |
| `innerRadius`, `outerRadius` | 40, 100 | The first ring |
| `sweep` | full turn | Angle of the first ring, `Math.PI` for a half |
| `direction` | up | Where the ring faces, in radians |
| `closeOnLeave` | false | Close once the pointer stays outside the falloff area. By default the menu stays |
| `closeOnSelect` | true | Close after a pick |
| `selectParents` | false | Items with children can be picked too |
| `profiles` | per pointer kind | Dead zone, falloff, scale, tap time and hysteresis for mouse, pen and touch |
| `hub` | true | Draw the centre disc |

Touch gets bigger targets and more forgiveness than a mouse. Override any of it with `profiles`.

Icons are a string, a texture, a container or a function that draws with SilkGraphics in white.
The menu tints them, so a hover colour costs no redraw.

## Make a button that waits

```ts

const play = new SilkButton({
    shape: 'circle',
    height: 76,
    icon: drawPlay,
    holdMs: 450,
    onPress: () => console.log('play'),
    onHold: () => console.log('play on repeat'),
});
const share = new SilkButton({ label: 'Share' });

await share.hide();        // resolves when the animation ends
play.state.busy.set(true); // spinner, input off
play.state.active.subscribe((on) => console.log('accepts input', on));
```

A button accepts input only when it is shown, settled, enabled and not busy. That is what keeps a
save button from being pressed twice while it saves. `show()` and `hide()` return promises that resolve `false`
if the animation was reversed half way. A press that slides a little off the button still counts
on release, within `hitPadding` (6 by default).

`ButtonDock` from `pixi-silk/ui/dock` lines buttons up and closes the gap when one hides. Each
button's space follows its animated appearance, so the reflow and the fade move together.

## State in signals

Every widget keeps its state in `state`: small signals you can read, write and subscribe to.
`pixi-silk/signal` has no Pixi code in it. A signal is a value with `subscribe`, with no
dependency tracking, so it plugs into React, MobX or plain callbacks.

```ts

const volume = signal(1);
const label = computed([volume], () => `volume ${volume.value}`);

volume.subscribe((now, before) => console.log(before, '->', now));
batch(() => {
    volume.set(2);
    volume.set(3);
}); // one notification
```

`computed` takes its sources as a list, so it recomputes only when one of them changes. Widgets
animate on `Ticker.shared` and leave the ticker once nothing moves. Pass `ticker` to use your
application's ticker, or `null` to step them yourself with `tick(dt)`.
