From 8fc94aad25bb794ccf91f432ca641a3175bfdb44 Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Fri, 20 Aug 2021 18:09:16 -0500 Subject: [PATCH] scripting: started on interact(), ui.select, chromas --- css/ui.css | 25 ++-- src/common/script/env.ts | 25 +++- src/common/script/lib/bitmap.ts | 10 +- src/common/script/lib/color.ts | 25 +++- src/common/script/lib/io.ts | 101 +++++++++++++- src/common/script/lib/scriptui.ts | 39 +++++- src/common/script/ui/notebook.ts | 215 +++++++++++++++++++----------- src/platform/script.ts | 15 --- 8 files changed, 330 insertions(+), 125 deletions(-) diff --git a/css/ui.css b/css/ui.css index 59bffc08..24804169 100644 --- a/css/ui.css +++ b/css/ui.css @@ -169,10 +169,6 @@ div.mem_info a.selected { .btn_label { color: #ccc; } -.btn_group.debug_group { -} -.btn_group.view_group { -} .btn_active { color: #33cc33 !important; } @@ -343,8 +339,6 @@ canvas.pixelated { a.toolbarMenuButton { padding:0.3em; } -a.dropdown-toggle { -} a.dropdown-toggle:hover { color:#ffffff; text-shadow: 0px 0px 2px black; @@ -767,8 +761,6 @@ div.asset_toolbar { .tree-level-9 { background-color: #7b8363;} .tree-level-10 { background-color: #738363;} -.waverow { -} .waverow.editable:hover { background-color: #336633; } @@ -794,7 +786,7 @@ div.asset_toolbar { image-rendering: crisp-edges; } .scripting-cell canvas:hover { - border-color:#ccc; + border-color:#aaa; } .scripting-flex canvas { min-height: 2vw; @@ -809,6 +801,8 @@ div.scripting-editor { } div.scripting-color { padding: 0.5em; + min-width: 3em; + min-height: 3em; } div.scripting-color span { visibility: hidden; @@ -825,3 +819,16 @@ div.scripting-flex { display: flex; flex-wrap: wrap; } +div.scripting-select > div { + border-radius: 2px; + border-style: dotted; + border-color: transparent; + +} +div.scripting-select > div:hover { + border-color: #eee; +} +div.scripting-select > .scripting-selected { + border-style: solid; + border-color: #eee; +} diff --git a/src/common/script/env.ts b/src/common/script/env.ts index 9cecdc94..f518f34b 100644 --- a/src/common/script/env.ts +++ b/src/common/script/env.ts @@ -75,7 +75,7 @@ export class Environment { } error(varname: string, msg: string) { let obj = this.declvars && this.declvars[varname]; - console.log(varname, obj); + console.log('ERROR', varname, obj, this); throw new RuntimeError(obj && obj.loc, msg); } print(args: any[]) { @@ -108,10 +108,18 @@ export class Environment { return parent() && parent().type === 'ExpressionStatement' && parent(2) && parent(2).type === 'Program'; } const convertTopToPrint = () => { - if (isTopLevel()) update(`print(${source()});`) + if (isTopLevel()) { + let printkey = `$$print__${this.seq++}`; + update(`this.${printkey} = io.data.load(${source()}, ${JSON.stringify(printkey)})`); + //update(`print(${source()});`) + } } const left = node['left']; switch (node.type) { + // add preamble, postamble + case 'Program': + update(`${this.preamble}${source()}${this.postamble}`) + break; // error on forbidden keywords case 'Identifier': if (GLOBAL_BADLIST.indexOf(source()) >= 0) { @@ -125,7 +133,7 @@ export class Environment { if (isTopLevel()) { if (left && left.type === 'Identifier') { if (!this.declvars[left.name]) { - update(`var ${left.name}=io.data.get(this.${source()}, ${JSON.stringify(left.name)})`) + update(`var ${left.name}=io.data.load(this.${source()}, ${JSON.stringify(left.name)})`) this.declvars[left.name] = left; } else { update(`${left.name}=this.${source()}`) @@ -157,7 +165,7 @@ export class Environment { code = this.preprocess(code); this.obj = {}; const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor; - const fn = new AsyncFunction('$$', this.preamble + code + this.postamble).bind(this.obj, this.builtins); + const fn = new AsyncFunction('$$', code).bind(this.obj, this.builtins); await fn.call(this); this.checkResult(this.obj, new Set(), []); } @@ -171,8 +179,9 @@ export class Environment { if (o.BYTES_PER_ELEMENT > 0) return; // typed array, don't bother checked.add(o); // so we don't recurse if cycle function prkey() { return fullkey.join('.') } + // go through all object properties recursively for (var [key, value] of Object.entries(o)) { - if (value == null && fullkey.length == 0) { + if (value == null && fullkey.length == 0 && !key.startsWith("$$")) { this.error(key, `"${key}" has no value.`) } fullkey.push(key); @@ -180,7 +189,7 @@ export class Environment { if (fullkey.length == 1) this.error(fullkey[0], `"${prkey()}" is a function. Did you forget to pass parameters?`); // TODO? did you mean (needs to see entire expr) else - this.error(fullkey[0], `This expression may be incomplete.`); // TODO? did you mean (needs to see entire expr) + this.error(fullkey[0], `This expression may be incomplete, or it contains a function object: ${prkey()}`); // TODO? did you mean (needs to see entire expr) } if (typeof value === 'symbol') { this.error(fullkey[0], `"${prkey()}" is a Symbol, and can't be used.`) // TODO? @@ -231,11 +240,12 @@ export class Environment { while (frame >= 0) { console.log(frames[frame]); if (frames[frame].fileName.endsWith('Function')) { + // TODO: use source map errors.push( { path: this.path, msg: e.message, line: frames[frame].lineNumber - LINE_NUMBER_OFFSET, - start: frames[frame].columnNumber, + //start: frames[frame].columnNumber, } ); } --frame; @@ -252,6 +262,7 @@ export class Environment { getLoadableState() { let updated = null; for (let [key, value] of Object.entries(this.declvars)) { + // TODO: use Loadable if (typeof value['$$getstate'] === 'function') { let loadable = value as io.Loadable; if (updated == null) updated = {}; diff --git a/src/common/script/lib/bitmap.ts b/src/common/script/lib/bitmap.ts index 18eec1e1..797fef60 100644 --- a/src/common/script/lib/bitmap.ts +++ b/src/common/script/lib/bitmap.ts @@ -47,14 +47,18 @@ export abstract class AbstractBitmap { dest.assign((x, y) => this.get(x + srcx, y + srcy)); return dest; } - blit(src: BitmapType, dest: BitmapType, + blit(src: BitmapType, destx: number, desty: number, srcx: number, srcy: number) { + destx |= 0; + desty |= 0; + srcx |= 0; + srcy |= 0; for (var y=0; y { - result.blit(bmp, result, x, y, 0, 0); + result.blit(bmp, x, y, 0, 0); hitrects.push({x, y, w: bmp.width, h: bmp.height }) x += bmp.width + gap; if (x >= result.width) { diff --git a/src/common/script/lib/color.ts b/src/common/script/lib/color.ts index 3976829a..2369d644 100644 --- a/src/common/script/lib/color.ts +++ b/src/common/script/lib/color.ts @@ -1,8 +1,10 @@ import _chroma from 'chroma-js' -import { isArray } from '../../util'; +import { isArray, rgb2bgr } from '../../util'; -export type ColorSource = number | [number,number,number] | [number,number,number,number] | string; +export type Chroma = { _rgb: [number,number,number,number] }; + +export type ColorSource = number | [number,number,number] | [number,number,number,number] | string | Chroma; function checkCount(count) { if (count < 0 || count > 65536) { @@ -10,6 +12,14 @@ function checkCount(count) { } } +export function isPalette(object): object is Palette { + return object['colors'] instanceof Uint32Array; +} + +export function isChroma(object): object is Chroma { + return object['_rgb'] instanceof Array; +} + export class Palette { readonly colors: Uint32Array; @@ -28,12 +38,18 @@ export class Palette { get(index: number) { return this.colors[index]; } + chromas() { + return Array.from(this.colors).map((rgba) => from(rgba & 0xffffff)); + } } export const chroma = _chroma; export function from(obj: ColorSource) { - return _chroma(obj as any); + if (typeof obj === 'number') + return _chroma(rgb2bgr(obj & 0xffffff)); + else + return _chroma(obj as any); } export function rgb(obj: ColorSource) : number; @@ -47,6 +63,9 @@ export function rgba(obj: ColorSource) : number; export function rgba(r: number, g: number, b: number, a: number) : number; export function rgba(obj: ColorSource, g?: number, b?: number, a?: number) : number { + if (isChroma(obj)) { + return rgba(obj._rgb[0], obj._rgb[1], obj._rgb[2], obj._rgb[3]); + } if (typeof obj === 'number') { let r = obj; if (typeof g === 'number' && typeof b === 'number') diff --git a/src/common/script/lib/io.ts b/src/common/script/lib/io.ts index 1752f3a7..eb1baf0e 100644 --- a/src/common/script/lib/io.ts +++ b/src/common/script/lib/io.ts @@ -7,6 +7,10 @@ var $$cache: WeakMap = new WeakMap(); var $$store: WorkingStore; // backing store for data var $$data: {} = {}; +// events +var $$seq = 0; +// if an event is specified, it goes here +export const EVENT_KEY = "$$event"; export function $$setupFS(store: WorkingStore) { $$store = store; @@ -20,18 +24,28 @@ export function $$loadData(data: {}) { // object that can load state from backing store export interface Loadable { - reset() : void; + // called during script, from io.data.load() + $$reset() : void; + $$setstate?(newstate: {}) : void; + // called after script, from io.data.save() $$getstate() : {}; } export namespace data { - export function get(object: Loadable, key: string): Loadable { + export function load(object: Loadable, key: string): Loadable { + if (object == null) return object; let override = $$data && $$data[key]; - if (override) Object.assign(object, override); - else if (object.reset) object.reset(); + if (override && object.$$setstate) { + object.$$setstate(override); + } else if (override) { + Object.assign(object, override); + } else if (object.$$reset) { + object.$$reset(); + data.save(object, key); + } return object; } - export function set(object: Loadable, key: string): Loadable { + export function save(object: Loadable, key: string): Loadable { if ($$data && object.$$getstate) { $$data[key] = object.$$getstate(); } @@ -114,3 +128,80 @@ export function readlines(url: string) : string[] { export function splitlines(text: string) : string[] { return text.split(/\n|\r\n/g); } + + +// an object that can become interactive, identified by ID +export interface Interactive { + $$interact: InteractionRecord; +} + +export interface InteractEvent { + interactid : number; + type: string; + x?: number; + y?: number; + button?: boolean; +} + +// InteractionRecord maps a target object to an interaction ID +// the $$callback is used once per script eval, then gets nulled +// whether or not it's invoked +// event comes from $$data.$$event +export class InteractionRecord implements Loadable { + interactid : number; + lastevent : {} = null; + constructor( + public readonly interacttarget: Interactive, + private $$callback + ) { + } + $$reset() { + this.$$setstate({interactid: ++$$seq}) + } + $$setstate(newstate: {interactid: number}) { + this.interactid = newstate.interactid; + this.interacttarget.$$interact = this; + let event : InteractEvent = $$data[EVENT_KEY]; + if (event && event.interactid == this.interactid) { + if (this.$$callback) { + this.$$callback(event); + } + this.lastevent = event; + $$data[EVENT_KEY] = null; + } + this.$$callback = null; + } + $$getstate() { + //TODO: this isn't always cleared before we serialize (e.g. if exception or move element) + this.$$callback = null; + return this; + } +} + +export function isInteractive(obj: object): obj is Interactive { + return !!((obj as Interactive).$$interact); +} + +export function interact(object: any, callback) : InteractionRecord { + // TODO: limit to Bitmap, etc + if (typeof object === 'object') { + return new InteractionRecord(object, callback); + } + throw new Error(`This object is not capable of interaction.`); +} + +// TODO: what if this isn't top level? +export class Mutable implements Loadable { + value : T; + constructor(public readonly initial : T) { } + $$reset() { + this.value = this.initial; + } + $$setstate(newstate) { + this.value = newstate.value; + } + $$getstate() { + return { value: this.value }; + } +} + diff --git a/src/common/script/lib/scriptui.ts b/src/common/script/lib/scriptui.ts index 6a7034a3..5ae3e764 100644 --- a/src/common/script/lib/scriptui.ts +++ b/src/common/script/lib/scriptui.ts @@ -1,7 +1,11 @@ import * as io from "./io"; -export class ScriptUISliderType { +export interface ScriptUIType { + uitype : string; +} + +export class ScriptUISliderType implements ScriptUIType { readonly uitype = 'slider'; value: number; constructor( @@ -18,7 +22,7 @@ export class ScriptUISlider extends ScriptUISliderType implements io.Loadable { this.initvalue = value; return this; } - reset() { + $$reset() { this.value = this.initvalue != null ? this.initvalue : this.min; } $$getstate() { @@ -29,3 +33,34 @@ export class ScriptUISlider extends ScriptUISliderType implements io.Loadable { export function slider(min: number, max: number, step?: number) { return new ScriptUISlider(min, max, step || 1); } + +/// + +export class ScriptUISelectType implements ScriptUIType { + readonly uitype = 'select'; + value: T; + index: number = -1; + constructor( + readonly options: T[] + ) { + } +} + +export class ScriptUISelect extends ScriptUISelectType implements io.Loadable { + initindex : number; + initial(index: number) { + this.initindex = index; + return this; + } + $$reset() { + this.index = this.initindex >= 0 ? this.initindex : -1; + this.value = null; + } + $$getstate() { + return { value: this.value, index: this.index }; + } +} + +export function select(options: any[]) { + return new ScriptUISelect(options); +} diff --git a/src/common/script/ui/notebook.ts b/src/common/script/ui/notebook.ts index ed0626b8..4c57e366 100644 --- a/src/common/script/ui/notebook.ts +++ b/src/common/script/ui/notebook.ts @@ -1,13 +1,14 @@ import { BitmapType, IndexedBitmap, RGBABitmap } from "../lib/bitmap"; -import { Component, render, h, ComponentType } from 'preact'; +import { Component, render, h, createRef, VNode } from 'preact'; import { Cell } from "../env"; -import { findIntegerFactors, hex, rgb2bgr, RGBA } from "../../util"; +import { findIntegerFactors, hex, isArray, rgb2bgr } from "../../util"; import { dumpRAM } from "../../emu"; // TODO: can't call methods from this end -import { Palette } from "../lib/color"; -import { ScriptUISlider, ScriptUISliderType } from "../lib/scriptui"; +import * as color from "../lib/color"; +import { ScriptUISelectType, ScriptUISliderType, ScriptUIType } from "../lib/scriptui"; import { current_project } from "../../../ide/ui"; +import { EVENT_KEY, InteractEvent, Interactive, isInteractive } from "../lib/io"; const MAX_STRING_LEN = 100; const DEFAULT_ASPECT = 1; @@ -44,18 +45,39 @@ class ColorComponent extends Component { let rgb = this.props.rgbavalue & 0xffffff; let bgr = rgb2bgr(rgb); var htmlcolor = `#${hex(bgr,6)}`; - var textcolor = (rgb & 0x008000) ? 'black' : 'white'; + var textcolor = (rgb & 0x008000) ? '#222' : '#ddd'; var printcolor = hex(rgb & 0xffffff, 6); // TODO: show index instead? return h('div', { - class: 'scripting-color', + class: 'scripting-item scripting-color', style: `background-color: ${htmlcolor}; color: ${textcolor}`, alt: htmlcolor, // TODO }, [ - h('span', { }, printcolor ) + //h('span', { }, printcolor ) ]); } } +function sendInteraction(iobj: Interactive, type: string, event: Event, xtraprops: {}) { + let irec = iobj.$$interact; + let ievent : 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.round(x); + ievent.y = Math.round(y); + // TODO: pressure, etc. + } + // TODO: add events to queue? + current_project.updateDataItems([{ + key: EVENT_KEY, + value: ievent + }]); +} + interface BitmapComponentProps { bitmap: BitmapType; width: number; @@ -63,20 +85,47 @@ interface BitmapComponentProps { } 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) { - return h('canvas', { + let props = { + class: 'scripting-item', + ref: this.ref, width: this.props.width, height: this.props.height, - ...this.props - }); + } + let obj : any = this.props.bitmap; + if (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, { }); + }, + onPointerUp: (e: PointerEvent) => { + this.pressed = false; + sendInteraction(obj, 'up', e, { }); + }, + onPointerOut: (e: PointerEvent) => { + this.pressed = false; + sendInteraction(obj, 'out', e, { }); + }, + ...props + }); + } else { + return h('canvas', props); + } } componentDidMount() { this.refresh(); @@ -124,56 +173,6 @@ class BitmapComponent extends Component { } } -interface BitmapEditorState { - isEditing: boolean; -} - -class BitmapEditor extends Component { - render(virtualDom, containerNode, replaceNode) { - // TODO: focusable? - let bitmapProps = { - onClick: (event) => { - if (!this.state.isEditing) { - this.setState({ isEditing: true }); - } else { - // TODO: process click - } - }, - ...this.props - } - let bitmapRender = h(BitmapComponent, bitmapProps, []); - let okCancel = null; - if (this.state.isEditing) { - okCancel = h('div', {}, [ - button('Ok', () => this.commitChanges()), - button('Cancel', () => this.abandonChanges()), - ]); - } - return h('div', { - tabIndex: 0, - class: this.state.isEditing ? 'scripting-editor' : '' // TODO - }, [ - bitmapRender, - okCancel, - ]) - } - commitChanges() { - this.cancelEditing(); - } - abandonChanges() { - this.cancelEditing(); - } - cancelEditing() { - this.setState({ isEditing: false }); - } -} - -function button(label: string, action: () => void) { - return h('button', { - onClick: action - }, [label]); -} - interface ObjectTreeComponentProps { name?: string; object: {} | []; @@ -191,7 +190,7 @@ class ObjectKeyValueComponent extends Component this.toggleExpand() : null }, [ h('span', { class: 'tree-key' }, [ propName, expandable ]), - h('span', { class: 'tree-value' }, [ getShortName(this.props.object) ]) + h('span', { class: 'tree-value scripting-item' }, [ getShortName(this.props.object) ]) ]), this.state.expanded ? objectToContentsDiv(this.props.object, this.props.objpath) : null ]); @@ -256,27 +255,39 @@ function isIndexedBitmap(object): object is IndexedBitmap { function isRGBABitmap(object): object is RGBABitmap { return object['rgba'] instanceof Uint32Array; } -function isPalette(object): object is Palette { - return object['colors'] 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 objectToDiv(object: any, name: string, objpath: string) { - var props = { class: '', key: `${objpath}__obj` }; - var children = []; - // TODO: tile editor +function objectToDiv(object: any, name: string, objpath: string): VNode { // TODO: limit # of items // TODO: detect table - //return objectToContentsDiv(object); if (object == null) { - return object + ""; + return h('span', { }, object + ""); } else if (object['uitype']) { - return h(UISliderComponent, { iokey: objpath, uiobject: object }); + 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(BitmapEditor, { bitmap: object, width: object.width, height: object.height }); - } else if (isPalette(object)) { - if (object.colors.length <= 64) { + 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 })); @@ -317,25 +328,27 @@ function objectToContentsDiv(object: {} | [], objpath: string) { return h('div', { class: 'scripting-flex' }, objectDivs); } -interface UISliderComponentProps { +/// + +interface UIComponentProps { iokey: string; - uiobject: ScriptUISliderType; + uiobject: ScriptUIType; } -class UISliderComponent extends Component { +class UISliderComponent extends Component { render(virtualDom, containerNode, replaceNode) { - let slider = this.props.uiobject; + let slider = this.props.uiobject as ScriptUISliderType; return h('div', {}, [ this.props.iokey, h('input', { type: 'range', min: slider.min / slider.step, max: slider.max / slider.step, - value: this.props.uiobject.value / slider.step, + value: slider.value / slider.step, onInput: (ev) => { - let newValue = { value: parseFloat(ev.target.value) * slider.step }; + let newUIValue = { value: parseFloat(ev.target.value) * slider.step }; this.setState(this.state); - current_project.updateDataItems([{key: this.props.iokey, value: newValue}]); + current_project.updateDataItems([{key: this.props.iokey, value: newUIValue}]); } }, []), getShortName(slider.value) @@ -343,6 +356,46 @@ class UISliderComponent extends Component { } } +class UISelectComponent extends Component { + ref = createRef(); + render(virtualDom, containerNode, replaceNode) { + let select = this.props.uiobject as ScriptUISelectType; + let children = objectToChildren(select.options); + return h('div', { + class: 'scripting-select scripting-flex', + ref: this.ref, + onClick: (e) => { + // 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; + } + const selindex = Array.from(target.parentElement.children).indexOf(target); + if (selindex >= 0 && selindex < children.length) { + let newUIValue = { value: children[selindex], index: selindex }; + this.setState(this.state); + 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 ]); + })) + // TODO: show current selection + } +} + +const UI_COMPONENTS = { + 'slider': UISliderComponent, + 'select': UISelectComponent, +} + +/// + export class Notebook { constructor( public readonly maindoc: HTMLDocument, diff --git a/src/platform/script.ts b/src/platform/script.ts index f8fadbf3..3b6f751e 100644 --- a/src/platform/script.ts +++ b/src/platform/script.ts @@ -11,16 +11,6 @@ class ScriptingPlatform implements Platform { constructor(mainElement: HTMLElement) { this.mainElement = mainElement; - /* - // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe - this.iframe = $(`