UNPKG

skia-canvas

Version:

A multi-threaded, GPU-accelerated, Canvas API for Node

353 lines (290 loc) 10.7 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 #events = 'native' // `native` for an OS event loop or `node` to poll for ui-events from node #started = false // whether the `eventLoop` property is permanently set #launcher // timer set by opening windows to ensure app is launched soon after #session // Promise that resolves when the current set of windows are all closed #windows = [] #frames = {} #fps = 60 constructor(){ super(App) // set the callback to use for event dispatch & rendering if (neon.App) this.ƒ("register", this.#dispatch.bind(this)) // track new windows and schedule launch if needed Window.events.on('open', win => { this.#windows.push(win) this.#frames[win.id] = 0 if (!this.#launcher) this.#launcher = setImmediate( () => this.launch() ) this.ƒ("openWindow", JSON.stringify(win.state), core(win.canvas.pages[win.state.page-1])) }) // drop closed windows Window.events.on('close', win => { this.#windows = this.#windows.filter(w => w!==win) this.ƒ("closeWindow", win.id) win.emit('close') }) } get windows(){ return [...this.#windows] } get running(){ return this.#started } get eventLoop(){ return this.#events } set eventLoop(mode){ if (this.#started) throw new Error("Cannot alter event loop after it has begun") if (['native', 'node'].includes(mode) && mode != this.#events){ this.#events = this.ƒ("setMode", mode) } } get fps(){ return this.#fps } set fps(rate){ checkSupport() if (rate >= 1 && rate != this.#fps){ this.#fps = this.ƒ('setRate', rate) } } launch(){ checkSupport() clearImmediate(this.#launcher) this.#started = true this.#session ??= this.ƒ('activate').finally(() => { this.#session = null this.#launcher = null this.emit('idle', {type:'idle', target:this}) }) return this.#session } #eachWindow(updates, callback){ for (const [id, payload] of Object.entries(updates || {})){ let win = this.#windows.find(win => win.id == id) if (win) callback(win, payload) } } #dispatch(isFrame, payload){ let {geom, state, ui} = JSON.parse(payload) // merge autogenerated window locations into newly opened windows if (geom) this.#eachWindow(geom, (win, {top, left}) => { win.left = win.left || left win.top = win.top || top }) // update state of windows that are still active and mark others as closed if (state) this.#windows = this.#windows.filter(win => { // keep active windows and new ones still waiting for a `geom` roundtrip to set their initial position if (win.id in state || win.top === undefined){ Object.assign(win, state[win.id]) return true } // but otherwise evict all windows that have been closed via title bar widget win.close() }) // deliver ui events to corresponding windows if (ui) this.#eachWindow(ui, (win, events) => { for (const [[type, e]] of events.map(o => Object.entries(o))){ switch(type){ case 'mouse': var {button, buttons, point, page_point:{x:pageX, y:pageY}, modifiers} = e win.emit(e.event, {button, buttons, ...point, pageX, pageY, ...modifiers}) break case 'input': let [data, inputType] = e win.emit(type, {data, inputType}) break case 'composition': win.emit(e.event, {data:e.data, locale:App.#locale}) break case 'keyboard': var {event, key, code, location, repeat, modifiers} = 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 if (isFrame) for (let win of this.#windows){ let frame = ++this.#frames[win.id] if (frame==0) win.emit("setup") win.emit("frame", {frame}) if (win.listenerCount('draw')){ win.canvas.getContext("2d").reset() win.emit("draw", {frame}) } } // if this is a full roundtrip, return window state & content return isFrame && [ JSON.stringify( this.#windows.map(win => win.state) ), this.#windows.map(win => core(win.canvas.pages[win.page-1]) ) ] } quit(){ this.ƒ("quit") } [REPR](depth, options) { let {eventLoop, fps, windows} = this return `App ${inspect({eventLoop, fps, windows}, Object.assign(options, { depth:1, customInspect:false }))}` } } // Mix the EventEmitter properties into App Object.assign(App.prototype, EventEmitter.prototype) class Window extends EventEmitter{ static events = new EventEmitter() static #kwargs = "id,left,top,width,height,title,page,background,fullscreen,cursor,fit,visible,resizable,borderless,closed".split(/,/) static #nextID = 1 #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 hasCanvas = opts.canvas instanceof Canvas let {textContrast=0, textGamma=1.4} = hasCanvas ? opts.canvas.engine : opts let canvas = hasCanvas ? opts.canvas : new Canvas(width, height, {textContrast, textGamma}) super(Window) this.#state = { title: "", visible: true, resizable: true, borderless: false, background: "white", fullscreen: false, closed: false, page: canvas.pages.length, left: undefined, top: undefined, width, height, textContrast, textGamma, cursor: "default", fit: "contain", id: Window.#nextID++ } Object.assign(this, {canvas}, Object.fromEntries( Object.entries(opts).filter(([k, v]) => Window.#kwargs.includes(k) && v!==undefined) )) Window.events.emit('open', this) } get state(){ return {...this.#state} } get ctx(){ return this.#canvas.pages[this.page-1] } get id(){ return this.#state.id } set id(id){ if (id!=this.id) throw new Error("Window IDs are immutable") } 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 this.#state.textContrast = canvas.engine.textContrast this.#state.textGamma = canvas.engine.textGamma } } 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 borderless(){ return this.#state.borderless } set borderless(flag){ this.#state.borderless = !!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.cursor } set cursor(icon){ if (css.cursor(icon)){ 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() } get closed(){ return this.#state.closed } close(){ if (!this.#state.closed){ this.#state.closed = true Window.events.emit('close', this) } } open(){ if (this.#state.closed){ this.#state.closed = false Window.events.emit('open', this) } } 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) } } [REPR](depth, options) { let info = Object.fromEntries(Window.#kwargs.map(k => [k, this.#state[k]])) return `Window ${inspect(info, options)}` } } module.exports = {App:new App(), Window}