skia-canvas
Version:
A multi-threaded, GPU-accelerated, Canvas API for Node
353 lines (290 loc) • 10.7 kB
JavaScript
//
// 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}