diff --git a/css/ui.css b/css/ui.css index 01a1585d..d39045d5 100644 --- a/css/ui.css +++ b/css/ui.css @@ -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; diff --git a/src/ide/pixeleditor.ts b/src/ide/pixeleditor.ts index 49fdb6a2..1335ac01 100644 --- a/src/ide/pixeleditor.ts +++ b/src/ide/pixeleditor.ts @@ -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 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'); + $('