UNPKG

skia-canvas

Version:

A GPU-accelerated Canvas Graphics API for Node

316 lines (256 loc) 9.42 kB
// // Windows & event handling // "use strict" const {EventEmitter} = require('events'), {RustClass, core, inspect, neon, REPR} = require('./neon'), {Canvas} = require('./canvas'), css = require('./css') const checkSupport = () => { if (!neon.App) throw new Error("Skia Canvas was compiled without window support") } class App extends RustClass{ static #locale = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || process.env.LANGUAGE #running #fps constructor(){ super(App) this.#running = false this.#fps = 60 } get windows(){ return [...GUI.windows] } get running(){ return this.#running } get fps(){ return this.#fps } set fps(rate){ checkSupport() if (rate >= 1 && rate != this.#fps){ this.#fps = this.ƒ('setRate', rate) } } launch(){ checkSupport() if (this.#running) return console.error('Application is already running') this.#running = true clearTimeout(GUI.launcher) // begin event loop (and never return) this.ƒ("launch", args => { let {ui, state, geom} = JSON.parse(args) // in the initial roundtrip only, merge the autogenerated window locations with the specs for (const [id, {top, left}] of Object.entries(geom || {})){ GUI.getWindow(id, win => { win.left = win.left || left win.top = win.top || top }) } // update local state based on ui modifications (and evict GUI.windows that have been closed) if (state) GUI.windows = GUI.windows.filter(win => { return win.state.id in (state || {}) && Object.assign(win, state[win.state.id]) }) // deliver ui events to corresponding windows for (const [id, events] of Object.entries(ui || {})){ GUI.getWindow(id, (win, frame) => { let modifiers = {} for (const [[type, e]] of events.map(o => Object.entries(o))){ switch(type){ case 'modifiers': var {control_key:ctrlKey, alt_key:altKey, super_key:metaKey, shift_key:shiftKey} = e modifiers = {ctrlKey, altKey, metaKey, shiftKey} break case 'mouse': var {button, x, y, pageX, pageY} = e e.events.forEach(type => win.emit(type, {x, y, pageX, pageY, button, ...modifiers})) break case 'input': win.emit(type, {data:e, inputType:'insertText'}) break case 'composition': win.emit(e.event, {data:e.data, locale:App.#locale}) break case 'keyboard': var {event, key, code, location, repeat} = e, defaults = true; win.emit(event, {key, code, location, repeat, ...modifiers, preventDefault:() => defaults = false }) // apply default keybindings unless e.preventDefault() was run if (defaults && event=='keydown' && !repeat){ let {ctrlKey, altKey, metaKey} = modifiers if ( (metaKey && key=='w') || (ctrlKey && key=='c') || (altKey && key=='F4') ){ win.close() }else if ( (metaKey && key=='f') || (altKey && key=='F8') ){ win.fullscreen = !win.fullscreen } } break case 'focus': if (e) win.emit('focus') else win.emit('blur') break case 'resize': if (win.fit == 'resize'){ win.ctx.prop('size', e.width, e.height) win.canvas.prop('width', e.width) win.canvas.prop('height', e.height) } win.emit(type, e) break case 'move': case 'wheel': win.emit(type, e) break case 'fullscreen': win.emit(type, {enabled: e}) break default: console.log(type, e); } } }) } // provide frame updates to prompt redraws GUI.nextFrame((win, frame) => { if (frame==0) win.emit("setup") win.emit("frame", {frame}) if (win.listenerCount('draw')){ win.canvas.width = win.canvas.width win.emit("draw", {frame}) } }) // refresh lazily if not doing a flipbook animation this.ƒ('setRate', GUI.needsFrameUpdates() ? this.#fps : 0) // update the display return [ JSON.stringify( GUI.windows.map(win => win.state) ), GUI.windows.map(win => core(win.canvas.pages[win.page-1]) ) ] }) GUI.windows = [] // if the launch call exited, the last window was closed } quit(){ this.ƒ("quit") } } class Window extends EventEmitter{ static #kwargs = "left,top,width,height,title,page,background,fullscreen,cursor,fit,visible,resizable".split(/,/) #canvas #state // accept either ƒ(width, height, {…}) or ƒ({…}) constructor(width=512, height=512, opts={}){ checkSupport() if (!Number.isFinite(width) || !Number.isFinite(height)){ opts = [...arguments].slice(-1)[0] || {} width = opts.width || (opts.canvas || {}).width || 512 height = opts.height || (opts.canvas || {}).height || 512 } let canvas = (opts.canvas instanceof Canvas) ? opts.canvas : new Canvas(width, height) super(Window) this.#state = { title: "", visible: true, resizable: true, background: "white", fullscreen: false, page: canvas.pages.length, left: undefined, top: undefined, width, height, cursor: "default", cursorHidden: false, fit: "contain", id: Math.random().toString(16) } Object.assign(this, {canvas}, Object.fromEntries( Object.entries(opts).filter(([k, v]) => Window.#kwargs.includes(k) && v!==undefined) )) GUI.openWindow(this) } get state(){ return this.#state } get ctx(){ return this.#canvas.pages[this.page-1] } get canvas(){ return this.#canvas } set canvas(canvas){ if (canvas instanceof Canvas){ canvas.getContext("2d") // ensure it has at least one page this.#canvas = canvas this.#state.page = canvas.pages.length } } get visible(){ return this.#state.visible } set visible(flag){ this.#state.visible = !!flag } get resizable(){ return this.#state.resizable } set resizable(flag){ this.#state.resizable = !!flag } get fullscreen(){ return this.#state.fullscreen } set fullscreen(flag){ this.#state.fullscreen = !!flag } get title(){ return this.#state.title } set title(txt){ this.#state.title = (txt != null ? txt : '').toString() } get cursor(){ return this.#state.cursorHidden ? 'none' : this.#state.cursor } set cursor(icon){ if (css.cursor(icon)){ this.#state.cursorHidden = icon == 'none' if (icon != 'none') this.#state.cursor = icon } } get fit(){ return this.#state.fit } set fit(mode){ if (css.fit(mode)) this.#state.fit = mode } get left(){ return this.#state.left } set left(val){ if (Number.isFinite(val)) this.#state.left = val } get top(){ return this.#state.top } set top(val){ if (Number.isFinite(val)) this.#state.top = val } get width(){ return this.#state.width } set width(val){ if (Number.isFinite(val)) this.#state.width = val } get height(){ return this.#state.height } set height(val){ if (Number.isFinite(val)) this.#state.height = val } get page(){ return this.#state.page } set page(val){ if (val < 0) val += this.#canvas.pages.length + 1 let page = this.#canvas.pages[val-1] if (page && this.#state.page != val){ let [width, height] = page.prop('size') this.#canvas.prop('width', width) this.#canvas.prop('height', height) this.#state.page = val } } get background(){ return this.#state.background } set background(c){ this.#state.background = (c != null ? c : '').toString() } emit(type, e){ // report errors in event-handlers but don't crash try{ super.emit(type, Object.assign({target:this, type}, e)) } catch(err){ console.error(err) } } close(){ GUI.closeWindow(this) } [REPR](depth, options) { let info = Object.fromEntries(Window.#kwargs.map(k => [k, this.#state[k]])) return `Window ${inspect(info, options)}` } } const GUI = { App: new App(), windows: [], frames: new WeakMap(), launcher: null, nextFrame(callback){ GUI.windows.forEach(win => { let frame = GUI.frames.get(win) || 0 GUI.frames.set(win, frame + 1) callback(win, frame) }) }, needsFrameUpdates(){ let names = GUI.windows.map(win => win.eventNames()).flat() return (names.includes('frame') || names.includes('draw')) }, getWindow(id, callback){ GUI.windows.filter(w => w.state.id==id).forEach(win => callback(win)) }, openWindow(win){ GUI.windows.push(win) if (!GUI.launcher) GUI.launcher = setTimeout( () => GUI.App.launch() ) neon.App.openWindow(JSON.stringify(win.state), core(win.canvas.pages[win.state.page-1])) }, closeWindow(win){ GUI.windows = GUI.windows.filter(w => w !== win) neon.App.closeWindow(win.state.id) } } module.exports = {App:GUI.App, Window}