diff --git a/css/ui.css b/css/ui.css index ce0ba4bd..0e7b5fa6 100644 --- a/css/ui.css +++ b/css/ui.css @@ -802,9 +802,8 @@ div.scripting-editor { max-height: 50vw; } div.scripting-color { - padding: 0.5em; - min-width: 3em; - min-height: 3em; + min-width: 2em; + min-height: 2em; border: 3px solid black; } div.scripting-color span { @@ -836,3 +835,14 @@ div.scripting-select > .scripting-selected { border-style: solid; border-color: #eee; } +div.scripting-cell button { + background-color: #333; + color: #fff; + padding: 0.5em; +} +div.scripting-cell button:hover { + border-color: #fff; +} +div.scripting-cell button.scripting-enabled { + background-color: #339999; +} \ No newline at end of file diff --git a/src/common/script/env.ts b/src/common/script/env.ts index 8d3051a5..13950138 100644 --- a/src/common/script/env.ts +++ b/src/common/script/env.ts @@ -192,7 +192,7 @@ export class Environment { 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 && !key.startsWith("$$")) { + if (value == null && fullkey.length == 0 && !key.startsWith("$")) { this.error(key, `"${key}" has no value.`) } fullkey.push(key); @@ -244,10 +244,22 @@ export class Environment { start: loc.column, }] } - // TODO: Cannot parse given Error object + // TODO: Cannot parse given Error object? let frames = ErrorStackParser.parse(e); let frame = frames.findIndex(f => f.functionName === 'anonymous'); let errors = []; + // if ErrorStackParser fails, resort to regex + if (frame < 0 && e.stack != null) { + let m = /.anonymous.:(\d+):(\d+)/g.exec(e.stack); + if (m != null) { + errors.push( { + path: this.path, + msg: e.message, + line: parseInt(m[1]) - LINE_NUMBER_OFFSET, + }); + } + } + // otherwise iterate thru all the frames while (frame >= 0) { console.log(frames[frame]); if (frames[frame].fileName.endsWith('Function')) { @@ -261,6 +273,7 @@ export class Environment { } --frame; } + // if no stack frames parsed, last resort error msg if (errors.length == 0) { errors.push( { path: this.path, diff --git a/src/common/script/lib/bitmap.ts b/src/common/script/lib/bitmap.ts index 15f1c2b7..187e00e1 100644 --- a/src/common/script/lib/bitmap.ts +++ b/src/common/script/lib/bitmap.ts @@ -4,7 +4,7 @@ import * as fastpng from 'fast-png'; import { Palette } from './color'; import * as io from './io' import * as color from './color' -import { findIntegerFactors, RGBA } from '../../util'; +import { coerceToArray, findIntegerFactors, RGBA } from '../../util'; export type PixelMapFunction = (x: number, y: number) => number; @@ -17,17 +17,16 @@ export abstract class AbstractBitmap { public readonly height: number, ) { } - abstract blank(width: number, height: number) : AbstractBitmap; - abstract setarray(arr: ArrayLike) : AbstractBitmap; - abstract set(x: number, y: number, val: number) : AbstractBitmap; + abstract setarray(arr: ArrayLike) : void; + abstract set(x: number, y: number, val: number) : void; abstract get(x: number, y: number): number; abstract getrgba(x: number, y: number): number; inbounds(x: number, y: number): boolean { return (x >= 0 && x < this.width && y >= 0 && y < this.height); } - assign(fn: ArrayLike | PixelMapFunction) { + assign(fn: ArrayLike | PixelMapFunction) : void { if (typeof fn === 'function') { for (let y=0; y { } else { throw new Error(`Illegal argument to assign(): ${fn}`) } - return this; } clone() : AbstractBitmap { - return this.blank(this.width, this.height).assign((x,y) => this.get(x,y)); + let bmp = this.blank(this.width, this.height); + bmp.assign((x,y) => this.get(x,y)); + return bmp; } crop(srcx: number, srcy: number, width: number, height: number) { let dest = this.blank(width, height); @@ -64,6 +64,13 @@ export abstract class AbstractBitmap { } } } + fill(destx: number, desty: number, width:number, height:number, value:number) { + for (var y=0; y { @@ -80,11 +87,9 @@ export class RGBABitmap extends AbstractBitmap { } setarray(arr: ArrayLike) { this.rgba.set(arr); - return this; } set(x: number, y: number, rgba: number) { if (this.inbounds(x,y)) this.rgba[y * this.width + x] = rgba; - return this; } get(x: number, y: number): number { return this.inbounds(x,y) ? this.rgba[y * this.width + x] : 0; @@ -92,8 +97,8 @@ export class RGBABitmap extends AbstractBitmap { getrgba(x: number, y: number): number { return this.get(x, y); } - blank(width: number, height: number) : RGBABitmap { - return new RGBABitmap(width, height); + blank(width?: number, height?: number) : RGBABitmap { + return new RGBABitmap(width || this.width, height || this.height); } clone() : RGBABitmap { let bitmap = this.blank(this.width, this.height); @@ -108,47 +113,55 @@ export abstract class MappedBitmap extends AbstractBitmap { constructor( width: number, height: number, - public readonly bitsPerPixel: number, + public readonly bpp: number, initial?: Uint8Array | PixelMapFunction ) { super(width, height); - if (bitsPerPixel != 1 && bitsPerPixel != 2 && bitsPerPixel != 4 && bitsPerPixel != 8) - throw new Error(`Invalid bits per pixel: ${bitsPerPixel}`); + if (bpp != 1 && bpp != 2 && bpp != 4 && bpp != 8) + throw new Error(`Invalid bits per pixel: ${bpp}`); this.pixels = new Uint8Array(this.width * this.height); if (initial) this.assign(initial); } setarray(arr: ArrayLike) { this.pixels.set(arr); - return this; } set(x: number, y: number, index: number) { if (this.inbounds(x,y)) this.pixels[y * this.width + x] = index; - return this; } get(x: number, y: number): number { return this.inbounds(x,y) ? this.pixels[y * this.width + x] : 0; } } +function getbpp(x : number | Palette) : number { + if (typeof x === 'number') return x; + if (x instanceof Palette) { + if (x.colors.length <= 2) return 1; + else if (x.colors.length <= 4) return 2; + else if (x.colors.length <= 16) return 4; + } + return 8; +} + export class IndexedBitmap extends MappedBitmap { public palette: Palette; constructor( width: number, height: number, - bitsPerPixel: number, + bppOrPalette: number | Palette, initial?: Uint8Array | PixelMapFunction ) { - super(width, height, bitsPerPixel || 8, initial); - this.palette = color.palette.colors(1 << this.bitsPerPixel); + super(width, height, getbpp(bppOrPalette), initial); + this.palette = bppOrPalette instanceof Palette + ? bppOrPalette + : color.palette.colors(1 << this.bpp); } - getrgba(x: number, y: number): number { return this.palette && this.palette.colors[this.get(x, y)]; } - blank(width: number, height: number) : IndexedBitmap { - let bitmap = new IndexedBitmap(width, height, this.bitsPerPixel); - bitmap.palette = this.palette; + blank(width?: number, height?: number, newPalette?: Palette) : IndexedBitmap { + let bitmap = new IndexedBitmap(width || this.width, height || this.height, newPalette || this.palette); return bitmap; } clone() : IndexedBitmap { @@ -184,6 +197,7 @@ export interface BitmapAnalysis { } export function analyze(bitmaps: BitmapType[]) { + bitmaps = coerceToArray(bitmaps); let r = {min:{w:0,h:0}, max:{w:0,h:0}}; for (let bmp of bitmaps) { if (!(bmp instanceof AbstractBitmap)) return null; @@ -202,6 +216,7 @@ export interface MontageOptions { } export function montage(bitmaps: BitmapType[], options?: MontageOptions) { + bitmaps = coerceToArray(bitmaps); let minmax = (options && options.analysis) || analyze(bitmaps); if (minmax == null) throw new Error(`Expected an array of bitmaps`); let hitrects = []; diff --git a/src/common/script/lib/io.ts b/src/common/script/lib/io.ts index c756236a..2c1a2fe4 100644 --- a/src/common/script/lib/io.ts +++ b/src/common/script/lib/io.ts @@ -1,12 +1,15 @@ +import { FileDataCache } from "../../util"; import { FileData, WorkingStore } from "../../workertypes"; // remote resource cache -var $$cache: WeakMap = new WeakMap(); +var $$cache = new FileDataCache(); // TODO: better cache? // file read/write interface var $$store: WorkingStore; // backing store for data var $$data: {} = {}; +// module cache +var $$modules: Map = new Map(); export function $$setupFS(store: WorkingStore) { $$store = store; @@ -38,7 +41,7 @@ export namespace data { return object; } export function save(object: Loadable, key: string): Loadable { - if ($$data && object.$$getstate) { + if ($$data && object && object.$$getstate) { $$data[key] = object.$$getstate(); } return object; @@ -67,10 +70,6 @@ export function canonicalurl(url: string) : string { return url; } -export function clearcache() { - $$cache = new WeakMap(); -} - export function fetchurl(url: string, type?: 'binary' | 'text'): FileData { // TODO: only works in web worker var xhr = new XMLHttpRequest(); @@ -99,17 +98,19 @@ export function readnocache(url: string, type?: 'binary' | 'text'): FileData { // TODO: read files too export function read(url: string, type?: 'binary' | 'text'): FileData { + // canonical-ize url url = canonicalurl(url); - // check cache - let cachekey = {url: url}; - if ($$cache.has(cachekey)) { - return $$cache.get(cachekey); - } - let data = readnocache(url, type); + // check cache first + let cachekey = url; + let data = $$cache.get(cachekey); + if (data != null) return data; + // not in cache, read it + data = readnocache(url, type); if (data == null) throw new Error(`Cannot find resource "${url}"`); if (type === 'text' && typeof data !== 'string') throw new Error(`Resource "${url}" is not a string`); if (type === 'binary' && !(data instanceof Uint8Array)) throw new Error(`Resource "${url}" is not a binary file`); - $$cache.set(cachekey, data); + // store in cache + $$cache.put(cachekey, data); return data; } @@ -129,6 +130,22 @@ export function splitlines(text: string) : string[] { return text.split(/\n|\r\n/g); } +export function module(url: string) { + // find module in cache? + let key = `${url}::${url.length}`; + let exports = $$modules.get(key); + if (exports == null) { + let code = readnocache(url, 'text') as string; + let func = new Function('exports', 'module', code); + let module = {}; // TODO? + exports = {}; + func(exports, module); + $$modules.set(key, exports); + } + return exports; +} + +/// // TODO: what if this isn't top level? export class Mutable implements Loadable { diff --git a/src/common/script/lib/scriptui.ts b/src/common/script/lib/scriptui.ts index 6d256b09..9ceda395 100644 --- a/src/common/script/lib/scriptui.ts +++ b/src/common/script/lib/scriptui.ts @@ -1,6 +1,10 @@ +import { coerceToArray } from "../../util"; import * as io from "./io"; +// sequence counter +var $$seq : number = 0; + // if an event is specified, it goes here export const EVENT_KEY = "$$event"; @@ -17,17 +21,22 @@ export interface InteractEvent { button?: boolean; } +export type InteractCallback = (event: InteractEvent) => void; + // 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 io.Loadable { + readonly interacttarget: Interactive; interactid : number; lastevent : {} = null; constructor( - public readonly interacttarget: Interactive, - private $$callback + interacttarget: Interactive, + private $$callback: InteractCallback ) { + this.interacttarget = interacttarget || (this as Interactive); + this.interactid = ++$$seq; } $$setstate(newstate: {interactid: number}) { this.interactid = newstate.interactid; @@ -46,7 +55,7 @@ export class InteractionRecord implements io.Loadable { //TODO: this isn't always cleared before we serialize (e.g. if exception or move element) //and we do it in checkResult() too this.$$callback = null; - return this; + return {interactid: this.interactid}; } } @@ -125,17 +134,42 @@ export function select(options: any[]) { /// -export class ScriptUIButtonType implements ScriptUIType { +export class ScriptUIButtonType extends InteractionRecord implements ScriptUIType, Interactive { readonly uitype = 'button'; + $$interact: InteractionRecord; + enabled?: boolean; + constructor( - readonly name: string + readonly label: string, + callback: InteractCallback ) { + super(null, callback); + this.$$interact = this; } } export class ScriptUIButton extends ScriptUIButtonType { } -export function button(name: string) { - return new ScriptUIButton(name); +export function button(name: string, callback: InteractCallback) { + return new ScriptUIButton(name, callback); +} + +export class ScriptUIToggle extends ScriptUIButton implements io.Loadable { + // share with InteractionRecord + $$getstate() { + let state = super.$$getstate() as any; + state.enabled = this.enabled; + return state; + } + $$setstate(newstate: any) { + this.enabled = newstate.enabled; + super.$$setstate(newstate); + } +} + +export function toggle(name: string) { + return new ScriptUIToggle(name, function(e) { + this.enabled = !this.enabled; + }); } diff --git a/src/common/script/ui/notebook.ts b/src/common/script/ui/notebook.ts index 2eb27b5c..b5cc362f 100644 --- a/src/common/script/ui/notebook.ts +++ b/src/common/script/ui/notebook.ts @@ -12,27 +12,27 @@ import * as scriptui from "../lib/scriptui"; const MAX_STRING_LEN = 100; const DEFAULT_ASPECT = 1; -interface ObjectStats { - type: 'prim' | 'complex' | 'bitmap' - width: number - height: number - units: 'em' | 'px' -} - -// TODO -class ObjectAnalyzer { - recurse(obj: any) : ObjectStats { - if (typeof obj === 'string') { - return { type: 'prim', width: obj.length, height: 1, units: 'em' } - } else if (obj instanceof Uint8Array) { - return { type: 'complex', width: 60, height: Math.ceil(obj.length / 16), units: 'em' } - } else if (typeof obj === 'object') { - let stats : ObjectStats = { type: 'complex', width: 0, height: 0, units: 'em'}; - return stats; // TODO - } else { - return { type: 'prim', width: 12, height: 1, units: 'em' } - } +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 { @@ -56,27 +56,6 @@ class ColorComponent extends Component { } } -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.round(x); - ievent.y = Math.round(y); - // TODO: pressure, etc. - } - // TODO: add events to queue? - current_project.updateDataItems([{ - key: scriptui.EVENT_KEY, - value: ievent - }]); -} - interface BitmapComponentProps { bitmap: bitmap.BitmapType; width: number; @@ -253,7 +232,7 @@ function primitiveToString(obj) { } function isIndexedBitmap(object): object is bitmap.IndexedBitmap { - return object['bitsPerPixel'] && object['pixels'] && object['palette']; + return object['bpp'] && object['pixels'] && object['palette']; } function isRGBABitmap(object): object is bitmap.RGBABitmap { return object['rgba'] instanceof Uint32Array; @@ -275,7 +254,10 @@ 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; } + 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 @@ -400,9 +382,24 @@ class UISelectComponent extends Component { } } +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 + ]) + } +} + const UI_COMPONENTS = { 'slider': UISliderComponent, 'select': UISelectComponent, + 'button': UIButtonComponent, } /// diff --git a/src/common/util.ts b/src/common/util.ts index df2a5e12..47c854ab 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -1,4 +1,3 @@ -import { UintArray } from "../ide/pixeleditor"; export function lpad(s:string, n:number):string { s += ''; // convert to string @@ -56,7 +55,7 @@ export function toradix(v:number, nd:number, radix:number) { } } -export function arrayCompare(a:any[]|UintArray, b:any[]|UintArray):boolean { +export function arrayCompare(a:ArrayLike, b:ArrayLike):boolean { if (a == null && b == null) return true; if (a == null) return false; if (b == null) return false; @@ -662,3 +661,34 @@ export function findIntegerFactors(x: number, mina: number, minb: number, aspect } return {a, b}; } + +export class FileDataCache { + maxSize : number = 8000000; + size : number; + cache : Map; + constructor() { + this.reset(); + } + get(key : string) : string|Uint8Array { + return this.cache.get(key); + } + put(key : string, value : string|Uint8Array) { + this.cache.set(key, value); + this.size += value.length; + if (this.size > this.maxSize) { + console.log('cache reset', this); + this.reset(); + } + } + reset() { + this.cache = new Map(); + this.size = 0; + } +} + +export function coerceToArray(arrobj: any) : T[] { + if (Array.isArray(arrobj)) return arrobj; + else if (arrobj != null && typeof arrobj[Symbol.iterator] === 'function') return Array.from(arrobj); + else if (typeof arrobj === 'object') return Array.from(Object.values(arrobj)) + else throw new Error(`Expected array or object, got "${arrobj}"`); +} diff --git a/src/ide/ui.ts b/src/ide/ui.ts index d306b65d..06cdf9fe 100644 --- a/src/ide/ui.ts +++ b/src/ide/ui.ts @@ -147,6 +147,7 @@ function fatalError(s:string) { } function newWorker() : Worker { + // TODO: return new Worker("https://8bitworkshop.com.s3-website-us-east-1.amazonaws.com/dev/gen/worker/bundle.js"); return new Worker("./gen/worker/bundle.js"); } diff --git a/src/platform/script.ts b/src/platform/script.ts index 3b6f751e..0664664b 100644 --- a/src/platform/script.ts +++ b/src/platform/script.ts @@ -1,5 +1,5 @@ -import { PLATFORMS, RasterVideo } from "../common/emu"; +import { PLATFORMS } from "../common/emu"; import { Platform } from "../common/baseplatform"; import { RunResult } from "../common/script/env"; import { Notebook } from "../common/script/ui/notebook";