Pixel art tools show only useful options and remove any colour (#201)
The pixel art side bar showed every tool option all the time, the palette used only part of the column width and "Remove" could only drop the last colour. Now each tool shows what it needs, the palette fills the column and any colour can be removed. - Options per tool live in toolOptions.ts. Brush shows for pencil, eraser, line and unfilled shapes. Filled shapes shows for rectangle and ellipse. Contiguous shows for fill. Picker has none. - All options share one row with a fixed height, so nothing below jumps when the tool changes. - The palette picks its column count from the width and stretches the swatches until the row ends flush. At 1280 px that is 11 per row instead of 8. - The colour field takes the space the two buttons leave, so "Remove" is no longer cut off. - "Remove" deletes the selected colour. Pixels of that colour turn transparent, later indices move down by one in every image that uses the palette, and palette plus images change in one undo step. Transparent itself cannot be removed. Decisions: - Pixels of a removed colour become transparent, like erasing (chosen by the user before the build). Findings: - The Filled checkbox slid away under the pointer. The brush button sat to its left and vanished when Filled turned on, so the second click missed. The brush button now sits to the right, and a check clicks the same spot twice. - One start-up FAIL "debug hook present" came back never in three later runs. It is the known start flake. Tests: - ./check.sh: 141 files, 2080 tests, exit 0 (before 140 / 2070) - 10 new unit tests in tests/editor/pixel-palette.test.ts - GM_STEPS=48 (fresh seed): ALL PASSED, 40 checks, 4 of them new - Counter-check: indices not shifting and brush always visible make 4 unit tests and exactly the 2 matching e2e checks fail - Check total 1282 (1278 + 4) in README and wiki, computed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
41772e53f7
commit
fe894c630d
10 files changed
+424
-55
No files matched your search
@@ -519,14 +519,19 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
return at ? { ...at, enabled: true, text: '+' } : null;
|
||||
}
|
||||
const widget = byName[name];
|
||||
if (!widget || !widget.visible) return null;
|
||||
// A tool's options hide in their row (#201), so a hidden parent hides the control as well.
|
||||
for (let at: Container | null = widget ?? null; at; at = at.parent) if (!at.visible) return null;
|
||||
if (!widget) return null;
|
||||
if (widget !== editor.pixelArtEditor.resizeButton) side.reveal(widget);
|
||||
return {
|
||||
...centreOf(widget),
|
||||
w: widget.w,
|
||||
enabled: widget.enabled,
|
||||
text: widget instanceof Button ? widget.text : '',
|
||||
};
|
||||
},
|
||||
/** Where the controls of the side panel may reach, left and right. */
|
||||
sideSpan: () => editor.pixelArtEditor.side.contentSpan(),
|
||||
swatch: (index: number) => {
|
||||
const side = editor.pixelArtEditor.side;
|
||||
side.reveal(side.palette);
|
||||
|
||||
@@ -3,18 +3,33 @@ import { type Size, Theme, Widget } from '../../ui';
|
||||
|
||||
const CELL = 20;
|
||||
const GAP = 2;
|
||||
export const PALETTE_COLUMNS = 8;
|
||||
|
||||
export interface PaletteLayout {
|
||||
columns: number;
|
||||
/** The side of a swatch: at least `CELL`, grown so the row ends flush with the width. */
|
||||
cell: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
/** As many swatches to a row as the width holds (#201); a width not known yet lays out eight. */
|
||||
export function paletteLayout(width: number, swatches: number): PaletteLayout {
|
||||
const columns = width > 0 ? Math.max(1, Math.floor((width + GAP) / (CELL + GAP))) : 8;
|
||||
const cell = width > 0 ? Math.max(CELL, Math.floor((width - (columns - 1) * GAP) / columns)) : CELL;
|
||||
return { columns, cell, rows: Math.max(1, Math.ceil(swatches / columns)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The colours of a palette as swatches, eight to a row (#130). The first swatch is index 0 –
|
||||
* transparent, drawn as a checkerboard – and the others follow the palette in order, so a swatch's
|
||||
* place is its index. A click picks it; the picked one is outlined.
|
||||
* The colours of a palette as swatches across the whole width (#130, #201). The first swatch is
|
||||
* index 0 – transparent, drawn as a checkerboard – and the others follow the palette in order, so a
|
||||
* swatch's place is its index. A click picks it; the picked one is outlined.
|
||||
*/
|
||||
export class PaletteGrid extends Widget {
|
||||
private readonly swatches = new Graphics();
|
||||
private colors: readonly number[] = [];
|
||||
private picked = 1;
|
||||
onPick: (index: number) => void = () => {};
|
||||
/** Told when the swatches need another number of rows, so the column around lays out again. */
|
||||
onReflow: () => void = () => {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -22,58 +37,69 @@ export class PaletteGrid extends Widget {
|
||||
this.cursor = 'pointer';
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
const local = this.toLocal(e.global);
|
||||
const column = Math.floor(local.x / (CELL + GAP));
|
||||
const row = Math.floor(local.y / (CELL + GAP));
|
||||
const index = row * PALETTE_COLUMNS + column;
|
||||
if (column < PALETTE_COLUMNS && index <= this.colors.length) this.onPick(index);
|
||||
const { columns, cell } = this.grid();
|
||||
const column = Math.floor(local.x / (cell + GAP));
|
||||
const row = Math.floor(local.y / (cell + GAP));
|
||||
const index = row * columns + column;
|
||||
if (column < columns && index <= this.colors.length) this.onPick(index);
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
private grid(): PaletteLayout {
|
||||
return paletteLayout(this.w, this.colors.length + 1);
|
||||
}
|
||||
|
||||
setColors(colors: readonly number[], picked: number): void {
|
||||
const grew =
|
||||
Math.ceil((colors.length + 1) / PALETTE_COLUMNS) !==
|
||||
Math.ceil((this.colors.length + 1) / PALETTE_COLUMNS);
|
||||
const before = this.grid().rows;
|
||||
this.colors = colors;
|
||||
this.picked = picked;
|
||||
if (grew) (this.parent as Widget | null)?.invalidate();
|
||||
if (this.grid().rows !== before) this.onReflow();
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
const rows = Math.max(1, Math.ceil((this.colors.length + 1) / PALETTE_COLUMNS));
|
||||
return { w: PALETTE_COLUMNS * (CELL + GAP), h: rows * (CELL + GAP) };
|
||||
const { columns, cell, rows } = this.grid();
|
||||
return { w: columns * (cell + GAP) - GAP, h: rows * (cell + GAP) };
|
||||
}
|
||||
|
||||
/** A new width can mean another number of rows, and so another height than the one given. */
|
||||
override layout(): void {
|
||||
if (this.measure().h !== this.h) queueMicrotask(() => this.onReflow());
|
||||
}
|
||||
|
||||
/** Where a swatch's centre sits on screen – the check scripts click it. */
|
||||
swatchGlobal(index: number): { x: number; y: number } {
|
||||
const p = this.getGlobalPosition();
|
||||
const { cell } = this.grid();
|
||||
const at = this.cellAt(index);
|
||||
return { x: p.x + at.x + CELL / 2, y: p.y + at.y + CELL / 2 };
|
||||
return { x: p.x + at.x + cell / 2, y: p.y + at.y + cell / 2 };
|
||||
}
|
||||
|
||||
private cellAt(index: number): { x: number; y: number } {
|
||||
const { columns, cell } = this.grid();
|
||||
return {
|
||||
x: (index % PALETTE_COLUMNS) * (CELL + GAP),
|
||||
y: Math.floor(index / PALETTE_COLUMNS) * (CELL + GAP),
|
||||
x: (index % columns) * (cell + GAP),
|
||||
y: Math.floor(index / columns) * (cell + GAP),
|
||||
};
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const g = this.swatches.clear();
|
||||
const { cell } = this.grid();
|
||||
for (let index = 0; index <= this.colors.length; index++) {
|
||||
const { x, y } = this.cellAt(index);
|
||||
if (index === 0) {
|
||||
g.rect(x, y, CELL, CELL).fill(0x9aa0aa);
|
||||
g.rect(x, y, CELL / 2, CELL / 2)
|
||||
.rect(x + CELL / 2, y + CELL / 2, CELL / 2, CELL / 2)
|
||||
g.rect(x, y, cell, cell).fill(0x9aa0aa);
|
||||
g.rect(x, y, cell / 2, cell / 2)
|
||||
.rect(x + cell / 2, y + cell / 2, cell / 2, cell / 2)
|
||||
.fill(0x5a5f69);
|
||||
} else g.rect(x, y, CELL, CELL).fill(this.colors[index - 1] ?? 0);
|
||||
g.rect(x + 0.5, y + 0.5, CELL - 1, CELL - 1).stroke({ width: 1, color: Theme.colors.border });
|
||||
} else g.rect(x, y, cell, cell).fill(this.colors[index - 1] ?? 0);
|
||||
g.rect(x + 0.5, y + 0.5, cell - 1, cell - 1).stroke({ width: 1, color: Theme.colors.border });
|
||||
}
|
||||
if (this.picked >= 0 && this.picked <= this.colors.length) {
|
||||
const { x, y } = this.cellAt(this.picked);
|
||||
g.rect(x - 1, y - 1, CELL + 2, CELL + 2).stroke({ width: 2, color: Theme.colors.accent });
|
||||
g.rect(x - 1, y - 1, cell + 2, cell + 2).stroke({ width: 2, color: Theme.colors.accent });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,8 @@ import {
|
||||
moveFrame,
|
||||
moveLayer,
|
||||
removeFrame,
|
||||
removeLastPaletteColor,
|
||||
removeLayer,
|
||||
removePaletteColor,
|
||||
renameLayer,
|
||||
setLayerHidden,
|
||||
setLayerOpacity,
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from './pixelActions';
|
||||
import { PixelPreview } from './PixelPreview';
|
||||
import { PixelThumbs } from './PixelThumbs';
|
||||
import { pixelToolOptions } from './toolOptions';
|
||||
|
||||
/** Four tools to a row, so the column stays narrow enough for the canvas beside it. */
|
||||
const TOOLS_PER_ROW = 4;
|
||||
@@ -64,10 +65,13 @@ export class PixelArtSide extends ScrollContainer {
|
||||
readonly brushButton = new Dropdown(brushLabel(1), { width: 120 });
|
||||
readonly filledBox = new Checkbox(false);
|
||||
readonly contiguousBox = new Checkbox(true);
|
||||
private readonly toolOptions: HStack;
|
||||
private readonly filledOption: HStack;
|
||||
private readonly contiguousOption: HStack;
|
||||
readonly palette = new PaletteGrid();
|
||||
readonly colorField = new ColorField(0);
|
||||
readonly addColorButton = new Button('+ Colour');
|
||||
readonly removeColorButton = new Button('Remove last');
|
||||
readonly addColorButton = new Button('Add');
|
||||
readonly removeColorButton = new Button('Remove');
|
||||
readonly createPaletteButton = new Button('Create palette');
|
||||
private readonly paletteNote = new Label('', { style: 'muted' });
|
||||
/** Each row names its layer (a double-click edits it), holds its opacity in percent and duplicates it. */
|
||||
@@ -104,25 +108,28 @@ export class PixelArtSide extends ScrollContainer {
|
||||
c.add(tools);
|
||||
}
|
||||
this.brushButton.onClick = () => this.openBrushMenu();
|
||||
c.add(row(this.brushButton));
|
||||
this.filledBox.onChange = (on) => (this.canvas.filled = on);
|
||||
this.filledBox.onChange = (on) => {
|
||||
this.canvas.filled = on;
|
||||
this.showToolOptions();
|
||||
};
|
||||
this.contiguousBox.onChange = (on) => (this.canvas.contiguous = on);
|
||||
c.add(
|
||||
row(
|
||||
this.filledBox,
|
||||
new Label('Filled shapes'),
|
||||
new Spacer(),
|
||||
this.contiguousBox,
|
||||
new Label('Contiguous'),
|
||||
),
|
||||
);
|
||||
this.filledOption = row(this.filledBox, new Label('Filled shapes'));
|
||||
this.contiguousOption = row(this.contiguousBox, new Label('Contiguous'));
|
||||
// One row for all of them: it keeps its height while a tool has none, so nothing below jumps.
|
||||
// The brush goes last, so the box that hides it stays under the pointer.
|
||||
this.toolOptions = row(this.filledOption, this.contiguousOption, new Spacer(), this.brushButton);
|
||||
c.add(this.toolOptions);
|
||||
|
||||
c.add(heading('Colours'));
|
||||
this.palette.onPick = (index) => this.canvas.setColor(index);
|
||||
this.palette.onReflow = () => this.relayoutContent();
|
||||
c.add(this.palette);
|
||||
this.colorField.onInput = (color) => this.previewColor(color);
|
||||
this.colorField.onChange = (color) => this.commitColor(color);
|
||||
c.add(row(this.colorField, this.addColorButton, this.removeColorButton));
|
||||
// The field takes what the two buttons leave, so the row ends inside the panel.
|
||||
const colorRow = row(this.colorField, this.addColorButton, this.removeColorButton);
|
||||
this.colorField.flex = 1;
|
||||
c.add(colorRow);
|
||||
this.addColorButton.onClick = () => this.addColor();
|
||||
this.removeColorButton.onClick = () => this.removeColor();
|
||||
this.createPaletteButton.onClick = () => this.createPalette();
|
||||
@@ -217,9 +224,24 @@ export class PixelArtSide extends ScrollContainer {
|
||||
this.ensureVisible(top, widget.h);
|
||||
}
|
||||
|
||||
/** Where the column's controls may reach on screen, inside its padding. */
|
||||
contentSpan(): { left: number; right: number } {
|
||||
const left = this.column.getGlobalPosition().x + this.column.padding;
|
||||
return { left, right: left + this.column.w - 2 * this.column.padding };
|
||||
}
|
||||
|
||||
setTool(tool: PixelTool): void {
|
||||
this.canvas.setTool(tool);
|
||||
for (const [t, button] of this.toolButtons) button.setToggled(t === tool);
|
||||
this.showToolOptions();
|
||||
}
|
||||
|
||||
private showToolOptions(): void {
|
||||
const shown = pixelToolOptions(this.canvas.tool, this.canvas.filled);
|
||||
this.brushButton.visible = shown.brush;
|
||||
this.filledOption.visible = shown.filled;
|
||||
this.contiguousOption.visible = shown.contiguous;
|
||||
this.toolOptions.relayout();
|
||||
}
|
||||
|
||||
setBrush(size: number): void {
|
||||
@@ -280,9 +302,10 @@ export class PixelArtSide extends ScrollContainer {
|
||||
if (index > 0) this.canvas.setColor(index);
|
||||
}
|
||||
|
||||
/** The picked colour goes; the one that moves into its place is picked next. */
|
||||
private removeColor(): void {
|
||||
const palette = this.paletteAsset();
|
||||
if (!palette || !removeLastPaletteColor(this.state, palette)) return;
|
||||
if (!palette || !removePaletteColor(this.state, palette, this.canvas.color)) return;
|
||||
if (this.canvas.color > palette.colors.length) this.canvas.setColor(palette.colors.length);
|
||||
}
|
||||
|
||||
@@ -370,7 +393,7 @@ export class PixelArtSide extends ScrollContainer {
|
||||
const editable = palette !== null && current !== null;
|
||||
this.colorField.setEnabled(editable);
|
||||
this.addColorButton.setEnabled(palette !== null);
|
||||
this.removeColorButton.setEnabled(palette !== null && palette.colors.length > 0);
|
||||
this.removeColorButton.setEnabled(editable);
|
||||
this.paletteNote.setText(
|
||||
palette ? `${palette.name} · ${palette.colors.length} colours` : 'Default colours',
|
||||
);
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
* pure state edits; resizing and merging also need the pixels, which only this browser holds, so
|
||||
* their new cels are worked out here and handed to the edit by name.
|
||||
*/
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../core/commands/pixelArtCommands';
|
||||
import { newId } from '../../core/ids';
|
||||
import type { PaletteAsset, PixelArtAsset } from '../../core/model/Asset';
|
||||
import { celFilesOf, celOf, PALETTE_MAX } from '../../core/model/pixelart';
|
||||
import { celFilesOf, celOf, isPixelArt, PALETTE_MAX, paletteAssetOf } from '../../core/model/pixelart';
|
||||
import { type CelPixels, emptyCel } from '../../core/model/pixelCel';
|
||||
import { mergeCels, type ResizeAnchor, resizeCel } from '../../core/model/pixelDraw';
|
||||
import { mergeCels, type ResizeAnchor, resizeCel, withoutColorIndex } from '../../core/model/pixelDraw';
|
||||
import {
|
||||
type PixelArtState,
|
||||
withCanvasSize,
|
||||
withCelsReplaced,
|
||||
withFrameAdded,
|
||||
withFrameMoved,
|
||||
withFrameRemoved,
|
||||
@@ -179,10 +181,31 @@ export function addPaletteColor(state: EditorState, palette: PaletteAsset, color
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the last colour can go: every pixel names its colour by index, and taking one out of the
|
||||
* middle would shift the colour of every pixel above it in every picture painted with the palette.
|
||||
* Colour `index` out of the palette (#201). Every pixel names its colour by index, so every picture
|
||||
* painted with the palette is rewritten with it: that colour's pixels turn transparent, the ones
|
||||
* after it move down one. Palette and pictures change in one undo step.
|
||||
*/
|
||||
export function removeLastPaletteColor(state: EditorState, palette: PaletteAsset): boolean {
|
||||
if (palette.colors.length === 0) return false;
|
||||
return state.execute(new SetPaletteColorsCommand(palette.id, 'Remove colour', palette.colors.slice(0, -1)));
|
||||
export function removePaletteColor(state: EditorState, palette: PaletteAsset, index: number): boolean {
|
||||
if (index < 1 || index > palette.colors.length) return false;
|
||||
const users = state.project.assets.filter(
|
||||
(asset): asset is PixelArtAsset => isPixelArt(asset) && paletteAssetOf(state.project, asset) === palette,
|
||||
);
|
||||
const rewrites: EditPixelArtCommand[] = [];
|
||||
for (const asset of users) {
|
||||
const cels = loadedCels(asset);
|
||||
if (!cels) {
|
||||
state.setNotice(STILL_LOADING);
|
||||
return false;
|
||||
}
|
||||
const replaced: Record<string, string> = {};
|
||||
for (const [file, cel] of cels) {
|
||||
const next = withoutColorIndex(cel, index);
|
||||
if (next) replaced[file] = pixelCels.put(next);
|
||||
}
|
||||
if (Object.keys(replaced).length > 0)
|
||||
rewrites.push(new EditPixelArtCommand(asset.id, 'Remove colour', (s) => withCelsReplaced(s, replaced)));
|
||||
}
|
||||
const colors = palette.colors.filter((_, i) => i !== index - 1);
|
||||
const recolor = new SetPaletteColorsCommand(palette.id, 'Remove colour', colors);
|
||||
return state.execute(oneStep('Remove colour', [recolor, ...rewrites]));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PixelTool } from './PixelArtCanvas';
|
||||
|
||||
export interface PixelToolOptions {
|
||||
brush: boolean;
|
||||
filled: boolean;
|
||||
contiguous: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The options that change what a tool paints (#201) – the side panel shows only these. A filled
|
||||
* rectangle or ellipse is filled pixel by pixel, so the brush size means nothing to it.
|
||||
*/
|
||||
export function pixelToolOptions(tool: PixelTool, filled: boolean): PixelToolOptions {
|
||||
const shape = tool === 'rect' || tool === 'ellipse';
|
||||
return {
|
||||
brush: tool === 'pencil' || tool === 'eraser' || tool === 'line' || (shape && !filled),
|
||||
filled: shape,
|
||||
contiguous: tool === 'fill',
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user