Source: app.mjs

import { Backends } from "./backend/backends.mjs";
import { Drawer } from "./draw/drawer.mjs";

/**
 * The type describing the loop mode of the app.
 */
export class LoopMode {
    nTimes;
    frameRate;
    refreshSync;

    constructor(refreshSync, nTimes, frameRate) {
        this.refreshSync = refreshSync;
        this.nTimes = nTimes;
        this.frameRate = frameRate;
    }

    /**
     * Create a loop mode that uses requestAnimationFrame()
     * @returns {LoopMode}
     */
    static RefreshSync() { return new LoopMode(true, null, null); }
    /**
     * Create a loop mode that uses setTimeout at the specified frame rate.
     * The framerate is specified at frames per second.
     * @param {number} framerate - the framerate the app should run at.
     * @returns {LoopMode}
     */
    static FrameRate(framerate) { return new LoopMode(null, null, framerate); }
    /**
     * Create a loop mode that loops a specific number of times and
     * the stops the app.
     * @param {number} nTimes - the number of times that app should loop
     * @returns {LoopMode}
     */
    static NTimes(nTimes) { return new LoopMode(null, nTimes, null); }
    /**
     * Create a loop mode that loops the app once and then stops the app.
     * This is a shorthand for calling NTimes(1).
     * @returns {LoopMode}
     */
    static Once() { return new LoopMode(null, 1, null); }

    get isRefreshSync() { return this.refreshSync != null; }
    get isNTimes() { return this.nTimes != null; }
    get isFrameRate() { return this.frameRate != null; }
}

/**
 * An amelia @type {App} builder.
 */
export class AppBuilder {

    canvasSize;
    viewFn;
    modelFn;
    mouseMoveFn;
    mousePressedFn;
    keyPressedFn;
    backend;
    parentElemId;
    loopMode;

    constructor() {
        this.viewFn = () => { };
        this.modelFn = () => { };
        this.mouseMoveFn = () => { };
        this.mousePressedFn = () => { };
        this.keyPressedFn = () => { };
        this.parentElemId = null;
        this.canvasSize = { w: 100, h: 100 };
        this.backend = Backends.Canvas2D;
        this.loopMode = LoopMode.RefreshSync();
    }

    /**
     * The default view function that the app will call to allow
     * you to draw to the current frame.
     * @param {*} viewFn - the view function that is called every frame
     * @returns {AppBuilder} - the app builder
     */
    view(viewFn) {
        this.viewFn = viewFn;

        return this;
    }

    /**
     * The default model function that the app will call before the
     * first frame.
     * @param {*} modelFn - the model function that is called before the first frame
     * @returns - the app builder
     */
    model(modelFn) {
        this.modelFn = modelFn;
        return this;
    }

    /**
     * The default function that is called when the mouse is moved.
     * @param {*} mouseMoveFn - the mouse move function
     * @returns {AppBuilder} - itself
     */
    mouseMove(mouseMoveFn) {
        this.mouseMoveFn = mouseMoveFn;
        return this;
    }

    /**
     * The default function that is called when the mouse is moved.
     * @param {*} mousePressedFn - the mouse press function
     * @returns {AppBuilder}
     */
    mousePress(mousePressedFn) {
        this.mousePressedFn = mousePressedFn;
        return this;
    }

    /**
     * The default function that is called when a key is pressed down.
     * @param {*} keyPressedFn - the key press function
     * @returns {AppBuilder}
     */
    keyPress(keyPressedFn) {
        this.keyPressedFn = keyPressedFn;
        return this;
    }

    /**
     * Specify the default canvas size in points.
     *
     * If the size is not specified or less or equal to zero,
     * the default size of 100x100 will be used.
     * @param {number} w - width
     * @param {number} h - height
     * @returns {AppBuilder} - the app builder
     */
    size(w, h) {
        this.canvasSize.w = w <= 0 ? 100 : w;
        this.canvasSize.h = h <= 0 ? 100 : h;

        return this;
    }

    /**
     * Specify the default canvas size using the @type {Size} type.
     *
     * If the size is less or equal to zero,
     * the default size will be 100x100 pixels.
     * @param {Size} sz - the size
     * @returns {AppBuilder}
     */
    sizeSz(sz) {
        return this.size(sz.width, sz.height);
    }

    /**
     * Specify the default backend to use for the canvas
     * drawing.
     * @param {Backends} backend - the backend to use
     * @returns {AppBuilder} - the app builder
     */
    backend(backend) {
        this.backend = backend;
        return this;
    }

    /**
     * Specify the DOM Element by its ID which should
     * be the parent of the canvas that the app creates.
     * @param {string} parentId - the DOM id
     * @returns {AppBuilder} - itself
     */
    parent(parentId) {
        this.parentElemId = parentId;
        return this;
    }

    /**
     * Specify the loop mode that the app should use.
     * @param {LoopMode} mode - the loop mode to use
     * @returns {AppBuilder}
     */
    loopmode(mode) {
        this.loopMode = mode;
        return this;
    }

    /**
     * Set the loop mode to LoopMode.Once().
     * Shorthand for loopmode(LoopMode.Once()).
     * @returns {AppBuilder}
     */
    once() {
        this.loopMode = LoopMode.Once();
        return this;
    }

    /**
     * Set the loop mode to LoopMode.FrameRate(frameRate).
     * Shorthand for loopmode(LoopMode.FrameRate(frameRate))
     * @param {number} frameRate the framerate the app should run at
     * @returns {AppBuilder}
     */
    framerate(frameRate) {
        this.loopMode = LoopMode.FrameRate(frameRate);
        return this;
    }

    /**
     * Set the loop mode to LoopMode.NTimes(times).
     * Shorthand for loopmode(LoopMode.NTimes(times))
     * @param {number} times iterations the app should run
     * @returns {AppBuilder}
     */
    ntimes(times) {
        this.loopMode = LoopMode.NTimes(times);
        return this;
    }

    /**
     * Build and run an @type {App} with the specified parameters.
     * This function will not return until the app has exited.
     */
    run() {
        const fns = {
            viewFn: this.viewFn,
            modelFn: this.modelFn,
            keyPressedFn: this.keyPressedFn,
            mousePressedFn: this.mousePressedFn,
            mouseMoveFn: this.mouseMoveFn
        };
        new App(fns, this.parentElemId, this.canvasSize, null, this.backend, this.loopMode).run();
    }

    /**
     * Quickly start an app.
     * Short-hand for .view(viewfn).size(w, h).run();
     * If no width and height are passed the size of the
     * canvas is set to 400x400 pixels.
     * @param {*} viewFn - the view function to use
     * @param {number} w - the width of the app, default is 400
     * @param {number} h - the height of the app, default is 400
     */
    quickstart(viewFn, w = 400, h = 400) {
        this.view(viewFn);
        this.size(w, h);
        this.run();
    }
}

/**
 * An amelia app instance usually created
 * using the {@link app} function.
 */
export class App {

    viewFn;
    modelFn;
    mouseMoveFn;
    mousePressFn;
    keyPressFn;
    model;
    size;
    drawer;
    frames;
    canvas;
    backendKind;
    parentElemId;
    loopMode;

    /** The frames-per-second the app is running at. */
    fps;
    times;

    constructor(fns, parentElemId, size, canvas, backendKind, loopMode) {
        this.viewFn = fns.viewFn;
        this.modelFn = fns.modelFn;
        this.mouseMoveFn = fns.mouseMoveFn;
        this.keyPressFn = fns.keyPressedFn;
        this.mousePressFn = fns.mousePressedFn;
        this.size = size;
        this.canvas = canvas;
        this.frames = 0;
        this.backendKind = backendKind;
        this.parentElemId = parentElemId;
        this.loopMode = loopMode;

        this.drawer = null;

        this.times = [];
    }

    run() {
        if (this.canvas == null) {
            this.canvas = document.createElement("canvas");
            this.canvas.width = this.size.w;
            this.canvas.height = this.size.h;

            this.canvas.addEventListener("mousedown", (ev) => {
                this.mousePressFn(this, this.model, ev);
            });
            this.canvas.addEventListener("mousemove", (ev) => {
                this.mouseMoveFn(this, this.model, ev);
            });
            document.addEventListener("keydown", (ev) => {
                this.keyPressFn(this, this.model, ev);
            });

            if (this.parentElemId) {
                let parentElem = document.getElementById(this.parentElemId);
                if (parentElem) {
                    parentElem.appendChild(this.canvas);
                } else {
                    console.warn(`The HTMLElement with id ${this.parentElemId} does not exist. Appending the canvas directly to the body.`);
                    document.body.appendChild(this.canvas);
                }
            } else {
                document.body.appendChild(this.canvas);
            }
        }

        this.drawer = new Drawer(this.canvas, this.backendKind);

        this.model = this.modelFn(this);

        if (this.loopMode.isRefreshSync) {
            requestAnimationFrame(this.#loop.bind(this));
        } else if (this.loopMode.isFrameRate) {
            setTimeout(this.#loop.bind(this), 1000 / this.loopMode.frameRate);
        } else if (this.loopMode.isNTimes && this.loopMode.nTimes > 0) {
            requestAnimationFrame(this.#loop.bind(this));
        }
    }

    #loop(ts) {
        let timestamp = ts || performance.now();
        while (this.times.length > 0 && this.times[0] <= timestamp - 1000) {
            this.times.shift();
        }
        this.times.push(timestamp);
        this.fps = this.times.length;

        this.viewFn(this, this.model);

        this.frames++;

        if (this.loopMode.isRefreshSync) {
            requestAnimationFrame(this.#loop.bind(this));
        } else if (this.loopMode.isFrameRate) {
            setTimeout(this.#loop.bind(this), 1000 / this.loopMode.frameRate);
        } else if (this.loopMode.isNTimes && this.frames < this.loopMode.nTimes) {
            requestAnimationFrame(this.#loop.bind(this));
        }
    }

    /**
     * Produce the App's Draw API for drawing geometry.
     * @returns {Drawer} draw API
     */
    draw() {
        return this.drawer;
    }

    /**
     * Produce the App's Draw API for drawing geometry
     * @returns {Drawer}
     */
    pen() {
        return this.drawer;
    }

    /**
     * The number of times the view function has been called
     * since the start of the program.
     * @returns {number} number of frames
     */
    iterations() {
        return this.frames;
    }

    /**
     * Specify the loop mode that the app should use.
     * This is used from the next frame on.
     * @param {LoopMode} mode - the loop mode to use
     * @returns {App}
     */
    loopmode(mode) {
        this.loopMode = mode;
        return this;
    }

    /**
     * The width of the app canvas
     */
    get width() {
        return this.canvas.width;
    }

    /**
     * The height of the app canvas
     */
    get height() {
        return this.canvas.height;
    }

    /**
     * Set the width of the app canvas
     */
    set width(width) {
        this.canvas.height = width;
    }

    /**
     * Set the height of the app canvas
     */
    set height(height) {
        this.canvas.height = height;
    }

    /**
     * Set the size of the app canvas using the {@link Size} type.
     * @param {Size} size - the size that the canvas should be set to
     */
    size(size) {
        this.width = size.width;
        this.height = size.height;
    }
}