skia-canvas
Version:
A GPU-accelerated Canvas Graphics API for Node
316 lines (256 loc) • 9.42 kB
JavaScript
//
// Windows & event handling
//
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}