renderer.js

/*
 * This file is part of AUX.
 *
 * AUX is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * AUX is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General
 * Public License along with this program; if not, write to the
 * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
 * Boston, MA  02110-1301  USA
 */

import {
  FRAME_SHIFT,
  PHASE_MASK,
  PHASE_RENDER,
  PHASE_CALCULATE,
} from './scheduler/scheduler.js';
import {
  getFirstBit,
  getLimbMask,
  createBitset,
  setBit,
  createBitList,
  setBitList,
  getBitIndex,
  printBitMask,
} from './scheduler/bitset.js';

function buildDependencyMap(tasks) {
  const m = new Map();

  tasks.forEach((task, i) => {
    task.dependencies.forEach((dependency) => {
      let tmp = m.get(dependency);

      if (!tmp) {
        tmp = [0, []];
        m.set(dependency, tmp);
      }

      tmp[0] |= 1 << task.phase;
      tmp[1].push(i);
    });
  });

  return new Map(
    Array.from(m.entries()).map(([dependency, tmp]) => {
      return [dependency, [tmp[0], createBitList(tmp[1])]];
    })
  );
}

function removeAnimation(animations, task) {
  for (let i = 0; i < animations.length; i++) {
    const animation = animations[i];

    if (animation.task === task) {
      animations.splice(i, 1);
    }
  }
}

function makeAnimation(frame, phase, run, task) {
  return { frame, phase, run, task };
}

export function deferRender(callback) {
  return [0, PHASE_RENDER, callback];
}

export function deferMeasure(callback) {
  return [0, PHASE_CALCULATE, callback];
}

export function deferRenderNext(callback) {
  return [1, PHASE_RENDER, callback];
}

export function deferMeasureNext(callback) {
  return [1, PHASE_CALCULATE, callback];
}

export function combineDefer(...args) {
  args = args.filter((arg) => Array.isArray(arg));

  if (args.length === 0) return null;
  if (args.length === 1) return args[0];

  const [frameOffset, phase] = args[0];

  for (let i = 1; i < args.length; i++) {
    if (args[i][0] !== args[0][0] || args[i][1] !== args[0][1])
      throw Error('Different defer calls cannot be combined.');
  }

  const callbacks = args.map((entry) => entry[2]);

  return [
    frameOffset,
    phase,
    () => {
      return combineDefer(...callbacks.map((cb) => cb()));
    },
  ];
}

export class Renderer {
  constructor() {
    // list of tasks
    this.tasks = [];

    // this maps dependencies (e.g. option names) to a
    // phase mask and a bitlist of tasks which depend on that
    // dependency
    this._dependencyMap = null;
  }

  getDependencyMap() {
    const dependencyMap = this._dependencyMap;

    if (dependencyMap) return dependencyMap;

    return (this._dependencyMap = buildDependencyMap(this.tasks));
  }

  run(frame, phase, runnable, animations, context) {
    let mask = 0;

    const tasks = this.tasks;

    // first run all runnable tasks
    for (let i = 0, iN = runnable.length; i < iN; i++) {
      let tmp = runnable[i];

      while (tmp !== 0) {
        const j = getFirstBit(tmp);
        const index = getBitIndex(i, j);

        //console.log('mask %d -> %d. index: %d', tmp, tmp & ~getLimbMask(j), index);

        tmp &= ~getLimbMask(j);

        if (getFirstBit(tmp) === j) throw new Error('boo');

        const task = tasks[index];

        // Check if the task is in the right phase
        if (task.phase !== phase) continue;

        removeAnimation(animations, task);

        try {
          //context.log('Running task %o', task);

          const result = task.run.call(context);

          if (result !== void 0) {
            if (typeof result === 'function') {
              animations.push(makeAnimation(frame, phase, result, task));
              mask |= 1 << phase;
              //context.log('Adding animation', makeAnimation(frame, phase, result, task));
            } else if (Array.isArray(result)) {
              const [frameOffset, phase, func] = result;
              animations.push(
                makeAnimation(frame + frameOffset, phase, func, task)
              );

              mask |= 1 << (phase + frameOffset * FRAME_SHIFT);
              //context.log('Adding animation', makeAnimation(frame + frameOffset, phase, func, task));
            }
          }
        } catch (err) {
          console.error('A task %o threw an error: %o', task, err);
        }

        // clear the bit
        runnable[i] &= ~getLimbMask(j);
      }
    }

    //context.log('Running animations: %o', animations.slice(0));

    // Now run all animations
    for (let i = 0; i < animations.length; i++) {
      const animation = animations[i];

      if (animation.phase !== phase) continue;

      if (animation.frame !== frame) {
        if (animation.frame < frame)
          console.error('Animation was never run: %o', animation);
        continue;
      }

      let stop = false;

      try {
        //context.log('Running animation %o', animation);

        const result = animation.run.call(context);

        if (result === null || result === void 0) {
          //console.log('Stopping animation', animation);
          stop = true;
        } else if (typeof result === 'function') {
          animation.run = result;
          animation.frame += 1;
          mask |= 1 << (phase + FRAME_SHIFT);
        } else if (Array.isArray(result)) {
          const [frameOffset, _phase, func] = result;

          animation.frame += frameOffset;
          animation.phase = _phase;
          animation.run = func;

          //console.log('Animation reschedule', result);
          mask |= 1 << (_phase + frameOffset * FRAME_SHIFT);
        }
      } catch (err) {
        console.error('An animation %o threw an error: %o', animation, err);
        stop = true;
      }

      if (stop) {
        //context.log('Animation %o stopped', animation);
        // animation is done.
        animations.splice(i, 1);
        i--;
      }
    }

    return mask;
  }

  addTask(task) {
    this.tasks.push(task);
  }

  scheduleTasks(dependency, runnable) {
    const tmp = this.getDependencyMap().get(dependency);

    if (tmp !== void 0) {
      const [mask, list] = tmp;
      setBitList(runnable, list);
      return mask;
    } else {
      return 0;
    }
  }

  scheduleAll(runnable) {
    let mask = 0;
    const tasks = this.tasks;

    for (let i = 0; i < tasks.length; i++) {
      mask |= 1 << tasks[i].phase;

      setBit(runnable, i);
    }

    return mask;
  }

  get taskCount() {
    return this.tasks.length;
  }
}

/**
 * Possible (rendering) task patterns.
 *
 * - Simple: Some options change which results in some DOM manipulation to
 *   happen (e.g. a CSS class being set or removed).
 *
 * - Multi-Phase rendering: Some options change which results in some DOM
 *   manipulation, however that manipulation may have to inspect styles or
 *   measure dimensions, which requires it to be scheduled into different
 *   phases.
 *
 * - Animations: An animation runs after some options changed (e.g. a meter
 *   falling) or continuously.
 *
 * - Recalculations: Some synthetic options are calculated based on some other
 *   options being changed. This usually needs to run on demand before all
 *   other tasks.
 *
 * - A resize (or some other non option dependency) triggers some tasks to run
 *   which need to be scheduled before possible dom modifications. That will
 *   usually trigger some internal options to be changed.
 *
 */

export function defineTask(phase, dependencies, run, debug) {
  if (!debug) debug = false;

  if (!Array.isArray(dependencies)) dependencies = [dependencies];

  const optionNames = dependencies.filter((dep) => typeof dep === 'string');

  if (optionNames.length) {
    const variableNames = optionNames.map((name) => {
      return name.replaceAll('.', '_');
    });
    const arglistIn = optionNames
      .map((name, i) => {
        if (name !== variableNames[i]) {
          return `[${JSON.stringify(name)}]: ${variableNames[i]}`;
        } else {
          return name;
        }
      })
      .join(', ');
    const arglistOut = ['this', ...variableNames].join(', ');

    run = new Function(
      'renderFunction',
      `
      return function () {
        const { ${arglistIn} } = this.options;
        return renderFunction.call(${arglistOut});
      };
    `
    )(run);
  }

  return {
    phase,
    dependencies,
    run,
    _dependencies: null,
    debug,
  };
}

export function defineRender(dependencies, run, debug) {
  return defineTask(PHASE_RENDER, dependencies, run, debug);
}

export function defineRecalculation(dependencies, run, debug) {
  return defineTask(PHASE_CALCULATE, dependencies, run, debug);
}

export function defineMeasure(dependencies, run, debug) {
  return defineRecalculation(dependencies, run, debug);
}

export function defineMultiPhaseRenderer(phase, dependencies, run, debug) {
  return defineTask(phase, dependencies, run, debug);
}

function notCalledError() {
  throw new Error('Not called in last frame.');
}

export class RenderState {
  constructor(scheduler, renderer, context) {
    this._runnable = createBitset(renderer.taskCount);
    this._animations = [];
    this._scheduled = 0;
    this._frame = 0;
    this._renderer = renderer;
    this._scheduler = scheduler;
    this._context = context;
    this._paused = true;
    this._pscheduled = 0;
    this._pframe = 0;
    this._run = (frame, phase) => {
      let scheduled = this._scheduled;

      //this.log('run(%d, %d)', frame, phase);

      if (frame !== this._frame) {
        if (scheduled & PHASE_MASK) notCalledError();

        scheduled >>= FRAME_SHIFT;
        //this.log('%d -> %d', this._scheduled, scheduled);
        this._frame = frame;

        if (!(scheduled & PHASE_MASK))
          throw new Error('Called but not scheduled.');
      }

      scheduled &= ~(1 << phase);
      this._scheduled = scheduled;

      if (!this._paused) {
        const { _renderer, _runnable, _animations, _context } = this;

        //this.log('Starting renderer.');

        const mask = _renderer.run(
          frame,
          phase,
          _runnable,
          _animations,
          _context
        );

        if (mask) this._schedule(mask);
      }
    };
    this.invalidateAll();
  }

  log(fmt, ...args) {
    this._context.log(fmt, ...args);
  }

  _adjustFrame() {
    const { _scheduler, _frame } = this;
    const frame = _scheduler.frame;

    if (_frame === frame) return false;

    if (this._scheduled & PHASE_MASK) notCalledError();

    this._scheduled >>= FRAME_SHIFT;
    this._frame = frame;
    return true;
  }

  _schedule(mask) {
    if (this._paused) {
      this._pscheduled |= mask;
    } else {
      this._adjustFrame();

      const { _scheduler, _scheduled } = this;

      mask &= ~_scheduled;

      if (mask === 0) return;

      _scheduler.schedule(mask, this._run);

      //this.log('scheduling for %d. Frame %d -> %d', mask, this._frame, frame);

      this._scheduled |= mask;
    }
  }

  invalidate(dependency) {
    const mask = this._renderer.scheduleTasks(dependency, this._runnable);

    if (mask) {
      //this.log('Scheduling for mask %d', mask);
      this._schedule(mask);
    }
  }

  invalidateAll() {
    const mask = this._renderer.scheduleAll(this._runnable);

    if (mask) this._schedule(mask);
  }

  getSchedulingStatus() {
    const { _frame, _scheduled, _runnable } = this;
    return {
      frame: _frame,
      scheduled: _scheduled,
      runnable: printBitMask(_runnable),
    };
  }

  pause() {
    if (this._paused) return;
    this._paused = true;
    this._pscheduled = this._scheduled;
    this._pframe = this._frame;
  }

  unpause() {
    if (!this._paused) return;
    this._paused = false;

    if (this._adjustFrame())
      this._animations.forEach((animation) => {
        if (animation.frame < this._frame) animation.frame = this._frame;
      });

    let pscheduled = this._pscheduled;
    const scheduled = this._scheduled;

    if (pscheduled === scheduled) return;

    const pframe = this._pframe;
    const frame = this._frame;

    if (pframe !== frame)
      pscheduled = (PHASE_MASK & pscheduled) | (pscheduled >> FRAME_SHIFT);

    if (pscheduled === scheduled) return;

    this._schedule(pscheduled);
  }

  isPaused() {
    return this._paused;
  }
}

export function getRenderers(Class) {
  if (!Class) return [];

  const parentRenderers = getRenderers(Object.getPrototypeOf(Class));
  const renderers = Class.renderers;

  return renderers ? parentRenderers.concat(renderers) : parentRenderers;
}