import { Component, render, h, createRef, VNode } from 'preact'; import { Cell, PROP_CONSTRUCTOR_NAME } from "../env"; import { findIntegerFactors, hex, isArray, rgb2bgr } from "../../util"; import { dumpRAM } from "../../emu"; import { current_project } from "../../../ide/ui"; // TODO: can't call methods from this end (e.g. Palette, Bitmap) import * as bitmap from "../lib/bitmap"; import * as color from "../lib/color"; import * as scriptui from "../lib/scriptui"; const MAX_STRING_LEN = 100; const DEFAULT_ASPECT = 1; function sendInteraction(iobj: scriptui.Interactive, type: string, event: Event, xtraprops: {}) { let irec = iobj.$$interact; let ievent : scriptui.InteractEvent = {interactid: irec.interactid, type, ...xtraprops}; if (event instanceof PointerEvent) { const canvas = event.target as HTMLCanvasElement; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (event.clientX - rect.left) * scaleX; const y = (event.clientY - rect.top) * scaleY; ievent.x = Math.floor(x); ievent.y = Math.floor(y); // TODO: pressure, etc. } else { console.log("Unknown event type", event); } // TODO: add events to queue? current_project.updateDataItems([{ key: scriptui.EVENT_KEY, value: ievent }]); } interface ColorComponentProps { rgbavalue: number; } class ColorComponent extends Component { render(virtualDom, containerNode, replaceNode) { let rgb = this.props.rgbavalue & 0xffffff; let bgr = rgb2bgr(rgb); var htmlcolor = `#${hex(bgr,6)}`; var textcolor = (rgb & 0x008000) ? '#222' : '#ddd'; var printcolor = hex(rgb & 0xffffff, 6); // TODO: show index instead? return h('div', { class: 'scripting-item scripting-color', style: `background-color: ${htmlcolor}; color: ${textcolor}`, alt: htmlcolor, // TODO }, [ //h('span', { }, printcolor ) ]); } } interface BitmapComponentProps { bitmap: bitmap.BitmapType; width: number; height: number; } class BitmapComponent extends Component { ref = createRef(); // TODO: can we use the ref? canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; imageData: ImageData; datau32: Uint32Array; pressed = false; constructor(props: BitmapComponentProps) { super(props); } render(virtualDom, containerNode, replaceNode) { let props = { class: 'scripting-item', ref: this.ref, width: this.props.width, height: this.props.height, style: this.props.bitmap.style } let obj : any = this.props.bitmap; if (scriptui.isInteractive(obj)) { return h('canvas', { onPointerMove: (e: PointerEvent) => { sendInteraction(obj, 'move', e, { pressed: this.pressed }); }, onPointerDown: (e: PointerEvent) => { this.pressed = true; this.canvas.setPointerCapture(e.pointerId); sendInteraction(obj, 'down', e, { pressed: true }); }, onPointerUp: (e: PointerEvent) => { this.pressed = false; sendInteraction(obj, 'up', e, { pressed: false }); }, onPointerOut: (e: PointerEvent) => { this.pressed = false; sendInteraction(obj, 'out', e, { pressed: false }); }, ...props }); } else { return h('canvas', props); } } componentDidMount() { this.refresh(); } componentWillUnmount() { this.canvas = null; this.imageData = null; this.datau32 = null; } componentDidUpdate(prevProps, prevState, snapshot) { this.refresh(); } prepare() { this.canvas = this.base as HTMLCanvasElement; this.ctx = this.canvas.getContext('2d'); this.imageData = this.ctx.createImageData(this.canvas.width, this.canvas.height); this.datau32 = new Uint32Array(this.imageData.data.buffer); } refresh() { // preact can reuse this component but it can change shape :^P if (this.canvas !== this.base || this.imageData.width != this.props.width || this.imageData.height != this.props.height) { this.prepare(); } this.updateCanvas(this.datau32, this.props.bitmap); this.ctx.putImageData(this.imageData, 0, 0); } updateCanvas(vdata: Uint32Array, bmp: bitmap.BitmapType) { if (bmp['palette']) { this.updateCanvasIndexed(vdata, bmp as bitmap.IndexedBitmap); } if (bmp['rgba']) { this.updateCanvasRGBA(vdata, bmp as bitmap.RGBABitmap); } } updateCanvasRGBA(vdata: Uint32Array, bmp: bitmap.RGBABitmap) { vdata.set(bmp.rgba); } updateCanvasIndexed(vdata: Uint32Array, bmp: bitmap.IndexedBitmap) { let pal = bmp.palette.colors; for (var i = 0; i < bmp.pixels.length; i++) { vdata[i] = pal[bmp.pixels[i]]; } } } interface ObjectTreeComponentProps { name?: string; object: {} | []; objpath: string; } interface ObjectTreeComponentState { expanded: boolean; } class ObjectKeyValueComponent extends Component { render(virtualDom, containerNode, replaceNode) { let expandable = typeof this.props.object === 'object'; let hdrclass = ''; if (expandable) hdrclass = this.state.expanded ? 'tree-expanded' : 'tree-collapsed' let propName = this.props.name || null; return h('div', { class: 'tree-content', key: `${this.props.objpath}__tree` }, [ h('div', { class: 'tree-header ' + hdrclass, onClick: expandable ? () => this.toggleExpand() : null }, [ propName != null ? h('span', { class: 'tree-key' }, [ propName, expandable ]) : null, h('span', { class: 'tree-value scripting-item' }, [ getShortName(this.props.object) ]) ]), this.state.expanded ? objectToContentsDiv(this.props.object, this.props.objpath) : null ]); } toggleExpand() { this.setState({ expanded: !this.state.expanded }); } } function getShortName(object: any) { if (typeof object === 'object') { try { var s = object[PROP_CONSTRUCTOR_NAME] || Object.getPrototypeOf(object).constructor.name; if (object.length > 0) { s += `[${object.length}]` } return s; } catch (e) { console.log(e); return e + ""; } } else { return primitiveToString(object); } } function primitiveToString(obj) { var text = ""; // is it a function? call it first, if we are expanded // TODO: only call functions w/ signature if (obj && obj.$$ && typeof obj.$$ == 'function' && this._content != null) { obj = obj.$$(); } // check null first if (obj == null) { text = obj + ""; // primitive types } else if (typeof obj == 'number') { if (obj != (obj | 0)) text = obj.toString(); // must be a float else text = obj + "\t($" + hex(obj) + ")"; } else if (typeof obj == 'string') { text = obj; } else { text = JSON.stringify(obj); } if (text.length > MAX_STRING_LEN) text = text.substring(0, MAX_STRING_LEN) + "..."; return text; } function isIndexedBitmap(object): object is bitmap.IndexedBitmap { return object['bpp'] && object['pixels'] && object['palette']; } function isRGBABitmap(object): object is bitmap.RGBABitmap { return object['rgba'] instanceof Uint32Array; } function objectToChildren(object: any) : any[] { if (color.isPalette(object)) { return new color.Palette(object.colors).chromas(); } else if (isArray(object)) { return Array.from(object); } else if (object != null) { return [ object ] } else { return [ ] } } function objectToChild(object: any, index: number) : any { if (color.isPalette(object)) { return color.from(object.colors[index]); } else if (isArray(object)) { return object[index]; } else if (object != null) { return object } else { return null } } function objectToDiv(object: any, name: string, objpath: string): VNode { // don't view any keys that start with "$" if (name && name.startsWith("$")) { // don't view any values that start with "$$" if (name.startsWith("$$")) return; // don't print if null or undefined if (object == null) return; // don't print key in any case name = null; } // TODO: limit # of items // TODO: detect table if (object == null) { return h('span', { }, object + ""); } else if (object['uitype']) { let cons = UI_COMPONENTS[object['uitype']]; if (!cons) throw new Error(`Unknown UI component type: ${object['uitype']}`); return h(cons, { iokey: objpath, uiobject: object }); } else if (object['literaltext']) { return h("pre", { }, [ object['literaltext'] ]); } else if (isIndexedBitmap(object) || isRGBABitmap(object)) { return h(BitmapComponent, { bitmap: object, width: object.width, height: object.height }); } else if (color.isChroma(object)) { return h(ColorComponent, { rgbavalue: color.rgb(object) }); } else if (color.isPalette(object)) { // TODO? if (object.colors.length <= 256) { let children = []; let props = { class: '', key: `${objpath}__obj` }; props.class += ' scripting-flex '; object.colors.forEach((val) => { children.push(h(ColorComponent, { rgbavalue: val })); }) return h('div', props, children); } else { let {a,b} = findIntegerFactors(object.colors.length, 1, 1, DEFAULT_ASPECT); return objectToDiv({ rgba: object.colors, width: a, height: b }, name, objpath); } } else { return h(ObjectKeyValueComponent, { name, object, objpath }, []); } } function fixedArrayToDiv(tyarr: Array, bpel: number, objpath: string) { const maxBytes = 0x100; if (tyarr.length <= maxBytes) { // TODO let dumptext = dumpRAM(tyarr, 0, tyarr.length); return h('pre', {}, dumptext); } else { let children = []; for (var ofs = 0; ofs < tyarr.length; ofs += maxBytes) { children.push(objectToDiv(tyarr.slice(ofs, ofs + maxBytes), '$' + hex(ofs), `${objpath}.${ofs}`)); } return h('div', {}, children); } } function objectToContentsDiv(object: {} | [], objpath: string) { // is typed array? let bpel = object['BYTES_PER_ELEMENT']; if (typeof bpel === 'number') { return fixedArrayToDiv(object as Array, bpel, objpath); } let objectEntries = Object.entries(object); let objectDivs = objectEntries.map(entry => objectToDiv(entry[1], entry[0], `${objpath}.${entry[1]}`)); return h('div', { class: 'scripting-flex' }, objectDivs); } /// interface UIComponentProps { iokey: string; uiobject: scriptui.ScriptUIType; dropdown?: boolean; } class UISliderComponent extends Component { render(virtualDom, containerNode, replaceNode) { let slider = this.props.uiobject as scriptui.ScriptUISliderType; return h('div', {}, [ this.props.iokey, h('input', { type: 'range', min: slider.min / slider.step, max: slider.max / slider.step, value: slider.value / slider.step, onInput: (ev) => { let target = ev.target as HTMLInputElement; // TODO let newUIValue = { value: parseFloat(target.value) * slider.step }; this.setState(this.state); current_project.updateDataItems([{key: this.props.iokey, value: newUIValue}]); } }), h('span', { }, getShortName(slider.value)), ]); } } class UISelectComponent extends Component { ref = createRef(); render(virtualDom, containerNode, replaceNode) { let select = this.props.uiobject as scriptui.ScriptUISelectType; let children = objectToChildren(select.options); this.props.dropdown = children.length > 16; let showselections = !this.props.dropdown || this.state.expanded; let seldiv = null; if (showselections) { seldiv = h('div', { class: 'scripting-select scripting-flex', ref: this.ref, onClick: (e) => { // select object -- iterate parents until we find select div, then find index of child let target = e.target as HTMLElement; while (target.parentElement && target.parentElement != this.ref.current) { target = target.parentElement; } if (target.parentElement) { const selindex = Array.from(target.parentElement.children).indexOf(target); if (selindex >= 0 && selindex < children.length) { let newUIValue = { value: children[selindex], index: selindex }; this.setState({ expanded: false }); current_project.updateDataItems([{key: this.props.iokey, value: newUIValue}]); } else { throw new Error(`Could not find click target of ${this.props.iokey}`); } } } }, children.map((child, index) => { let div = objectToDiv(child, null, `${this.props.iokey}__select_${index}`); let selected = (index == select.index); return h('div', { class: selected ? 'scripting-selected' : '' }, [ div ]); })); } if (this.props.dropdown) { let selectedDiv = objectToDiv(objectToChild(select.options, select.index), null, `${this.props.iokey}__selected`); return h('div', { class: 'tree-content', key: `${this.props.iokey}__tree` }, [ h('div', { class: 'tree-header ' + (this.state.expanded ? 'tree-expanded' : 'tree-collapsed'), onClick: () => this.toggleExpand() }, [ this.props.iokey, h('span', { class: 'tree-value scripting-item' }, [ selectedDiv, //getShortName(select.options) ]) ]), seldiv ]); } else { return seldiv; } } toggleExpand() { this.setState({ expanded: !this.state.expanded }); } } class UIButtonComponent extends Component { render(virtualDom, containerNode, replaceNode) { let button = this.props.uiobject as scriptui.ScriptUIButtonType; return h('button', { class: button.enabled ? 'scripting-button scripting-enabled' : 'scripting-button', onClick: (e: MouseEvent) => { sendInteraction(button, 'click', e, { }); }, }, [ button.label ]) } } class UIShortcutComponent extends Component { render(virtualDom, containerNode, replaceNode) { let shortcut = this.props.uiobject as scriptui.ScriptUIShortcut; // TODO: needs to fire on container node return h('div', { onKeyDown: (e: KeyboardEvent) => { sendInteraction(shortcut, 'key', e, { }); }, }, [ ]) } } const UI_COMPONENTS = { 'slider': UISliderComponent, 'select': UISelectComponent, 'button': UIButtonComponent, 'shortcut': UIShortcutComponent, } /// export class Notebook { constructor( public readonly maindoc: HTMLDocument, public readonly maindiv: HTMLElement ) { maindiv.classList.add('vertical-scroll'); } updateCells(cells: Cell[]) { let hTree = cells.map(cell => { return h('div', { class: 'scripting-cell', key: `${cell.id}__cell` }, [ objectToDiv(cell.object, cell.id, cell.id) ]) }); render(hTree, this.maindiv); } }