asset editor: PaletteColorPicker for large color swatches (like 555)

This commit is contained in:
Steven Hugg committed 2026-09-11 16:13:02 +02:00
1 parent df3128cf4c
commit 4f719500eb
4 files changed
+314 -7

No files matched your search

+51
View File
@@ -726,6 +726,57 @@ div.asset_toolbar {
padding:8px;
margin:8px;
}
.asset_colorpicker {
display:flex;
flex-direction:column;
align-items:center;
gap:0.5em;
font-family: "Andale Mono", "Menlo", "Lucida Console", monospace;
}
.asset_colorpicker_preview {
width:4em;
height:4em;
border:2px solid #333;
border-radius:0.5em;
}
.asset_colorpicker_colorinput {
width:4em;
height:2em;
}
.asset_colorpicker_hex {
color:#222;
font-size:0.85em;
white-space:nowrap;
}
.asset_colorpicker_controls {
display:flex;
flex-direction:column;
gap:0.25em;
}
.asset_colorpicker_row {
display:flex;
align-items:center;
gap:0.5em;
}
.asset_colorpicker_row label {
width:1.2em;
text-align:right;
}
.asset_colorpicker_slider {
width:12em;
}
.asset_colorpicker_value {
width:1.5em;
text-align:left;
}
.asset_colorpicker_grid {
max-height:20em;
max-width:32em;
overflow:auto;
}
.asset_colorpicker_grid div.asset_grid {
margin:0;
}
.control-insns {
margin-top: auto;
margin-bottom: 2em;
+203
View File
@@ -565,6 +565,48 @@ export function convertPaletteFormat(palbytes: UintArray, palfmt: PixelEditorPal
return newpalette;
}
// Direct-color palette formats (e.g. pal:444) encode RGB channels in the word
// itself instead of indexing a fixed table. getDirectColorChannels() describes
// where each channel lives so the palette editor can offer RGB controls rather
// than a grid of thousands of swatches.
export type DirectColorChannels = {
r: number; g: number; b: number; // bits per channel
rshift: number; gshift: number; bshift: number; // bit offsets in the word
};
export function getDirectColorChannels(palfmt: PixelEditorPaletteFormat): DirectColorChannels | null {
var pal = palfmt.pal;
if (typeof pal !== 'number' || !isFinite(pal)) return null;
var rr = Math.floor(Math.abs(pal / 100) % 10);
var gg = Math.floor(Math.abs(pal / 10) % 10);
var bb = Math.floor(Math.abs(pal) % 10);
if (rr + gg + bb <= 0) return null;
if (pal >= 0)
return { r: rr, g: gg, b: bb, rshift: 0, gshift: rr, bshift: rr + gg };
// negative pal: convertPaletteBytes(arr, rr+gg, bb, rr, gg, 0, rr)
return { r: bb, g: gg, b: rr, rshift: rr + gg, gshift: rr, bshift: 0 };
}
// Pack per-channel values into a direct-color palette word (inverse of
// decodeDirectColorWord / convertPaletteFormat).
export function encodeDirectColorWord(palfmt: PixelEditorPaletteFormat, r: number, g: number, b: number): number {
var c = getDirectColorChannels(palfmt);
if (!c) return 0;
return ((r & ((1 << c.r) - 1)) << c.rshift)
| ((g & ((1 << c.g) - 1)) << c.gshift)
| ((b & ((1 << c.b) - 1)) << c.bshift);
}
export function decodeDirectColorWord(palfmt: PixelEditorPaletteFormat, word: number): { r: number; g: number; b: number } | null {
var c = getDirectColorChannels(palfmt);
if (!c) return null;
return {
r: (word >> c.rshift) & ((1 << c.r) - 1),
g: (word >> c.gshift) & ((1 << c.g) - 1),
b: (word >> c.bshift) & ((1 << c.b) - 1),
};
}
// TODO: illegal colors?
const PREDEF_PALETTES = {
// LCD polarity: index 0 = lightest (pixel off), 3 = darkest (pixel on)
@@ -1124,6 +1166,167 @@ export class ImageChooser {
}
}
// A color picker for a single palette entry.
//
// Direct-color formats (pal:444, pal:332, ...) encode RGB in the word itself;
// a 444 palette has 4096 possible colors so showing a swatch for each one is
// unusable. Those get an RGB color input plus per-channel sliders. Indexed
// palettes get a scrollable swatch grid (their tables are small).
export class PaletteColorPicker {
palfmt: PixelEditorPaletteFormat;
rgbimgs: Uint32Array[];
channels: DirectColorChannels | null;
onpick: (word: number) => void;
viewers: Viewer[] = [];
sliders: { input: HTMLInputElement, valueLabel: JQuery, channel: string }[] = [];
previewEl: JQuery;
colorInput: HTMLInputElement;
hexEl: JQuery;
currentWord: number = 0;
constructor(palfmt: PixelEditorPaletteFormat, rgbimgs: Uint32Array[], onpick: (word: number) => void) {
this.palfmt = palfmt;
this.rgbimgs = rgbimgs;
this.channels = getDirectColorChannels(palfmt);
this.onpick = onpick;
}
recreate(parentdiv: JQuery, currentWord: number) {
parentdiv.empty();
this.viewers = [];
this.sliders = [];
this.previewEl = null;
this.colorInput = null;
this.hexEl = null;
if (this.channels)
this.createSliders(parentdiv);
else
this.createGrid(parentdiv);
this.setCurrent(currentWord);
}
private paletteColor(word: number): number {
var cols = convertPaletteFormat([word], this.palfmt);
return (cols && cols[0]) || 0xff000000;
}
private updateColorWidgets(word: number) {
var rgb = this.paletteColor(word);
var hexcol = '#' + hex(rgb2bgr(rgb & 0xffffff), 6);
if (this.previewEl) this.previewEl.css('background-color', hexcol);
if (this.colorInput && this.colorInput.value !== hexcol) this.colorInput.value = hexcol;
if (this.hexEl) this.hexEl.text(hexcol + ' $' + hex(word, 4));
}
setCurrent(word: number) {
this.currentWord = word;
if (this.channels) {
var c = decodeDirectColorWord(this.palfmt, word);
if (c) {
for (var s of this.sliders) {
var v = s.channel === 'r' ? c.r : s.channel === 'g' ? c.g : c.b;
s.input.value = String(v);
s.valueLabel.text(String(v));
}
}
this.updateColorWidgets(word);
} else {
this.viewers.forEach((v, i) => $(v.canvas).toggleClass('selected', i === word));
}
}
private channelBits(name: string): number {
var c = this.channels;
if (!c) return 0;
return name === 'r' ? c.r : name === 'g' ? c.g : c.b;
}
private pickFromSliders() {
var vals: { [k: string]: number } = { r: 0, g: 0, b: 0 };
for (var s of this.sliders) vals[s.channel] = Number(s.input.value);
var word = encodeDirectColorWord(this.palfmt, vals['r'], vals['g'], vals['b']);
this.setCurrent(word);
this.onpick(word);
}
private createSliders(parentdiv: JQuery) {
var c = this.channels;
var box = newDiv(parentdiv, 'asset_colorpicker');
this.previewEl = newDiv(box, 'asset_colorpicker_preview');
// native <input type=color> only addresses 8 bits/channel
if (c.r <= 8 && c.g <= 8 && c.b <= 8) {
this.colorInput = document.createElement('input');
this.colorInput.type = 'color';
this.colorInput.className = 'asset_colorpicker_colorinput';
box.append(this.colorInput);
}
this.hexEl = newDiv(box, 'asset_colorpicker_hex');
var controls = newDiv(box, 'asset_colorpicker_controls');
for (var name of ['r', 'g', 'b']) {
var bits = this.channelBits(name);
if (bits <= 0) continue;
var row = newDiv(controls, 'asset_colorpicker_row');
$('<label/>').text(name.toUpperCase()).appendTo(row);
var input = document.createElement('input');
input.type = 'range';
input.min = '0';
input.max = String((1 << bits) - 1);
input.step = '1';
input.className = 'asset_colorpicker_slider';
row.append(input);
var valueLabel = $('<span class="asset_colorpicker_value"/>').appendTo(row);
input.value = '0';
this.sliders.push({ input, valueLabel, channel: name });
$(input).on('input', () => this.pickFromSliders());
}
if (this.colorInput) $(this.colorInput).on('input', () => {
// native color input is 8 bits/channel; quantize to the palette's depth
var v = this.colorInput.value;
var r = parseInt(v.substring(1, 3), 16);
var g = parseInt(v.substring(3, 5), 16);
var b = parseInt(v.substring(5, 7), 16);
var word = encodeDirectColorWord(this.palfmt,
r >> (8 - c.r), g >> (8 - c.g), b >> (8 - c.b));
this.setCurrent(word);
this.onpick(word);
});
}
private createGrid(parentdiv: JQuery) {
var scroll = newDiv(parentdiv, 'asset_colorpicker_grid');
var agrid = newDiv(scroll, 'asset_grid');
var imgsperline = 16;
var span = null;
this.rgbimgs.forEach((imdata, i) => {
var viewer = new Viewer();
viewer.width = 1;
viewer.height = 1;
viewer.recreate();
viewer.canvas.style.width = '16px';
viewer.canvas.style.height = '16px';
viewer.updateImage(imdata);
$(viewer.canvas).addClass('asset_cell');
$(viewer.canvas).attr('title', '$' + hex(i) + ' #' + hex(rgb2bgr(imdata[0] & 0xffffff), 6));
$(viewer.canvas).click(() => {
this.setCurrent(i);
this.onpick(i);
});
this.viewers.push(viewer);
if (!span) {
span = $('<span/>');
agrid.append(span);
}
span.append(viewer.canvas);
if ((i % imgsperline) === imgsperline - 1) {
agrid.append($('<br/>'));
span = null;
}
});
}
}
function newDiv(parent?, cls?: string) {
var div = $(document.createElement("div"));
if (parent) div.appendTo(parent)
+8 -7
View File
@@ -212,8 +212,12 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext {
addPaletteEditorViews(parentdiv: JQuery, pal2rgb: pixed.PaletteFormatToRGB, callback): () => void {
var adual = $('<div class="asset_dual"/>').appendTo(parentdiv);
var aeditor = $('<div class="asset_editor"/>').hide(); // contains editor, when selected
// Direct-color palettes (pal:444 etc.) have thousands of colors; the picker
// edits RGB directly instead of enumerating every swatch.
var directcolor = pixed.getDirectColorChannels(pal2rgb.palfmt);
var allrgbimgs = [];
pal2rgb.getAllColors().forEach((rgba) => { allrgbimgs.push(new Uint32Array([rgba])); }); // array of array of 1 rgb color (for picker)
if (!directcolor)
pal2rgb.getAllColors().forEach((rgba) => { allrgbimgs.push(new Uint32Array([rgba])); }); // array of array of 1 rgb color (for picker)
var atable = $('<table/>').appendTo(adual);
aeditor.appendTo(adual);
// make default layout if not exists
@@ -252,14 +256,11 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext {
cells.push({ cell, index: i });
updateCell(cell, i);
cell.click((e) => {
var chooser = new pixed.ImageChooser();
chooser.rgbimgs = allrgbimgs;
chooser.width = 1;
chooser.height = 1;
chooser.recreate(aeditor, (index, newvalue) => {
callback(i, index);
var picker = new pixed.PaletteColorPicker(pal2rgb.palfmt, allrgbimgs, (word) => {
callback(i, word);
updateCell(cell, i);
});
picker.recreate(aeditor, pal2rgb.words[i]);
this.setCurrentEditor(aeditor, cell, pal2rgb);
});
});
+52
View File
@@ -0,0 +1,52 @@
import assert from "assert";
import { describe, it } from "mocha";
import {
getDirectColorChannels, encodeDirectColorWord, decodeDirectColorWord,
convertPaletteFormat, getPaletteLength
} from "../../src/ide/pixeleditor";
describe('Direct-color palette formats', function () {
it('should describe channel layout for pal:444', function () {
assert.deepEqual(getDirectColorChannels({ pal: 444 }),
{ r: 4, g: 4, b: 4, rshift: 0, gshift: 4, bshift: 8 });
});
it('should describe channel layout for pal:332', function () {
assert.deepEqual(getDirectColorChannels({ pal: 332 }),
{ r: 3, g: 3, b: 2, rshift: 0, gshift: 3, bshift: 6 });
});
it('should return null for indexed palettes and non-numeric formats', function () {
assert.equal(getDirectColorChannels({ pal: "nes" }), null);
assert.equal(getDirectColorChannels({ pal: "pacman" }), null);
assert.equal(getDirectColorChannels({}), null);
});
it('should pack and unpack channel values', function () {
var word = encodeDirectColorWord({ pal: 444 }, 0xF, 0x0, 0xA);
assert.equal(word, 0xA0F);
assert.deepEqual(decodeDirectColorWord({ pal: 444 }, word), { r: 0xF, g: 0x0, b: 0xA });
});
it('should round-trip every color in a pal:444 palette', function () {
var words = getPaletteLength({ pal: 444 });
assert.equal(words, 4096);
for (var i = 0; i < words; i++) {
var c = decodeDirectColorWord({ pal: 444 }, i);
assert.equal(encodeDirectColorWord({ pal: 444 }, c.r, c.g, c.b), i);
}
});
it('should match convertPaletteFormat for encoded words', function () {
var word = encodeDirectColorWord({ pal: 444 }, 0xF, 0x0, 0xA);
assert.equal(convertPaletteFormat([word], { pal: 444 })[0] >>> 0, 0xffa000f0);
});
it('should round-trip a pal:332 word', function () {
var word = encodeDirectColorWord({ pal: 332 }, 5, 3, 2);
var c = decodeDirectColorWord({ pal: 332 }, word);
assert.deepEqual(c, { r: 5, g: 3, b: 2 });
});
});