Custom Scripts - mpaperno/TouchPortal-Dynamic-Icons GitHub Wiki
The "Run Custom Script" action (introduced in plugin v1.3) is a powerful feature allowing full creativity using JavaScript with access to the drawing canvas of the current icon instance. The scripting environment should feel familiar to anyone with experience drawing using the standard HTML Canvas API.
ONLY RUN SCRIPTS YOU TRUST! Scripts can access system-level resources at the same user level (permissions) that Touch Portal is running as. This means reading or modifying unprotected files, among other possible malicious or misguided actions.
This document describes the environment and features available within scripts. It assumes familiarity with JavaScript.
Familiarity with scripting for non-browser environments, like NodeJS, is also helpful. If you're not familiar, one thing to keep in mind is that the JS runs within a NodeJS instance, not a browser, so any HTML/DOM-specific features are not available (nor needed). The only way to "display" something is by drawing it onto the provided canvas context.
A script file is specified as a path in the "Run Custom Script" action. (Relative paths are resolved against the "Default Image File Path" plugin setting.) The specified script is loaded, parsed and, optionally, cached. Since loading and parsing the script takes time, enabling the cache greatly improves efficiency if the action is being executed multiple times.
However, this means if you edit a cached script, the changes will not be reloaded automatically and the old version will continue to be executed. It is recommended to disable the cache while actively working on a script, so a fresh copy is loaded every time, then enable it again once finished.
An argument string can also be specified in the "Run Custom Script" action. This value will be available in the script as a global scriptArgs
string variable
(more on this later). Arguments can also be updated using the "Update a Value" action (which simply runs the cached script with the updated argument string).
It is up to your script to parse the argument string. For structured data this could be formatted as JSON, for example, and decoded in the script. Or it could be a simple list of arguments split by commas (like one would pass to a function).
Each "Run Custom Script" action creates an isolated JavaScript "global environment" in which the script is executed (technically, a NodeJS VM "context"). This environment persists between runs of the script, meaning any variables/objects created on the first run will be available in subsequent executions as well. This allows a script to keep its own internal state between runs (for example to increment some value each time, or create reusable elements which would be "expensive" to create each time). The environment is reset only when the icon instance is deleted (or the plugin restarts, of course).
This environment is essentially like running any standalone script with NodeJS, with all the built-in modules available.
Globals, like Math
, are available w/out any extra imports.
Other modules can be imported with require()
function as usual (mostly), eg.
var fs = fs || require("fs");
// or
if (!readFileSync)
var { readFileSync } = require("fs");
(It first checks if those variables already exist to avoid re-importing on every run.... continue reading to see why.)
Note that to persist a variable between script runs it is important not to re-define it each time. For example:
var scriptRuns = 0;
console.log(++scriptRuns);
This will output 1
each time the script is run because scriptRuns
is declared and set anew every time.
Instead, one could only set scriptRuns
variable once if it doesn't already exist.
var scriptRuns = scriptRuns || 0;
// or
if (scriptRuns == undefined)
var scriptRuns = 0;
console.log(++scriptRuns);
Now scriptRuns
will increment properly each time the script is run (as long as the icon instance is not delted, of course).
To avoid repetitive code one could also create a single "guard" variable and place any number of definitions, or other initialization code, behind it, for example:
if (!initComplete) {
var initComplete = true,
scriptRuns = 0;
try {
var inputData = JSON.parse(require("fs").readFileSync("input-data.json"));
}
catch (ex) { console.error("Error reading input-data:", ex); }
}
Note on using let
and const
:
Since the environment is persistent, let
and const
types can only be declared within a code block
(a function, if/let/for/while {}
block, fenced {...}
, etc). If declared outside a block, they will work the first time, but throw an error on
subsequent script runs since they will be considered re-definitions. Only var
(and function
) types can be declared at the top level of a script.
In addition to the standard global variables and types available from NodeJS itself, script instances run by the plugin are provided with a few additional variables and a number of classes, functions, and enumerations useful for drawing things. Most of the utility classes used for creating elements via Touch Portal actions are also available for use within custom scripts (for example "Text," "Image," "Simple Round Gauge", etc).
Most importantly, scripts provided with an instance of the current
CanvasRenderingContext2D
which is being used for drawing the current icon.
This is what all your drawing commands should be applied to. The Canvas
instance to which this context belongs is what is eventually rendered and sent to
Touch Portal as a state value. If the same icon instance has other drawing actions preceding the "Run Custom Script" action, then this canvas context will
already contain whatever those actions drew on it (and following actions may draw over it). Otherwise it will be a blank canvas (literally).
Here's a listing of the extra global variables, their type, and short descriptions:
/** The canvas context to draw into. */
var canvasContext: CanvasRenderingContext2D;
/** A class representing a rectangle with `x`, `y`, `width`, & `height` properties describing the area to draw into.
This is typically the same width & height as the main icon instance, with `x` and `y` both `0`. */
var paintRectangle: Rectangle;
/** Argument string specified in the "Run Custom Script" action's "Arguments" field or via "Update Value" action. */
var scriptArgs: string;
/** A class representing the current icon instance that is running this script.
It has useful properties such as `name`, `width` & `height`. */
var parentIcon: DynamicIcon;
/** A class for writing output to the Dynamic Icons plugin log file. See notes below. */
var logger: Logger;
Further details of types mentioned above (and all other included classes and utility functions) are available in the online scripting reference.
The standard NodeJS console is available to use like normal, and the output will be written to the Touch Portal log file (it should be visible in the TP "Logs" tab window if logging for this plugin is enabled in the log filter settings).
In contrast, using the logger
instance will write output to the plugin's own log file (not Touch Portal's).
It has the following methods: debug()
, info()
, warn()
, error()
, and trace()
. Their usage is equivalent to their console
counterparts.
One advantage of using the logger
methods is that they will automatically include the file and line number from where they were called along with the logged message.
As mentioned, scripts (and this plugin itself) run in NodeJS, which doesn't have any native HTML elements, and in particular the HTMLCanvas
type which
is used in browsers for drawing. So we use an external 3rd-party module which provides equivalent functionality (with some extensions) but whose output
(the Canvas) we can export as an image (and send to Touch Portal as a state value).
This module is called skia-canvas
and has very good reference documentation which includes links to both the HTML
standard types/methods and the extensions exclusive to skia-canvas
itself.
In addition to Canvas
and CanvasRenderingContext2D
APIs, it provides "auxiliary" classes like Path2D
, Image
, DOMMatrix
and others.
(Ignore the Window
and App
classes, they're not relevant to our application.)
The following skia-canvas
creatable types and two methods are available in the global environment (w/out needing to be "require()
'd"):
Canvas, DOMMatrix, DOMPoint, DOMRect, Image, ImageData, Path2D,
loadImage(), loadImageData()
A fully documented listing of all custom classes and functions is available in the online scripting reference.
There is also an exported definitions file available to provide code hinting in compatible editors (such as VSCode
).
The defintions describe everything available in the global scope, all skia-canvas types, and all custom types from the online reference.
(Although the definitions are in TypeScript format, they should still provide hinting for plain JS code.)
Place this file somewhere "close" to your scripts and reference it using a "triple slash directive" at the top of your script, like so:
/// <reference path="dynamic-icons.d.ts"/>
Here is a very basic script which writes "Hello World", or whatever is passed in scriptArgs
, rotated by a random angle between -45 and 45 degrees
in the center of the current icon.
/// <reference path="dynamic-icons.d.ts"/>
/** The function is just a way to wrap our code so we can use local variables and such.
`canvasContext`, `paintRectangle`, and `scriptArgs` are variables set in the global environment.
@param ctx { CanvasRenderingContext2D }
@param rect { Rectangle }
*/
function paint(ctx = canvasContext, rect = paintRectangle)
{
const angle = (Math.random() * 90 - 45) * Math.PI / 180,
text = scriptArgs || "Hello World!";
// logger.debug(`Hello world from ${parentIcon.name}! Args: "${scriptArgs}"; Angle: ${angle}`);
// Set all style properties on the context; these are all standard
ctx.shadowOffsetX = -3;
ctx.shadowOffsetY = -3;
ctx.shadowColor = 'magenta';
ctx.fillStyle = 'cyan';
// Set text reference point to the middle so it is simpler to rotate later
ctx.textAlign = 'center';
ctx.textBaseline = 'ideographic';
// Set font size to 14% of the smaller of icon width or height ("vmin")
ctx.font = "bold 14vmin sans-serif";
// skia-canvas allows text wrapping, unlike standard canvas
ctx.textWrap = true;
// Rotate canvas by the random angle;
// note non-standard version of `rotate()` which allows specifying a rotation
// reference point, here the center of the drawing rectangle, instead of the default (0,0).
ctx.rotate(angle, rect.center);
// Draw the text at center of icon's rectangle, limiting the text width to 75%
// of the current icon width, which may cause the text to wrap.
ctx.fillText(text, rect.center.x, rect.center.y, rect.width * .75);
}
// We have to invoke our function every time the script runs.
paint();
This next example does the same thing as the first but using a custom Dynamic Icons class named StyledText
to do the actual drawing.
In fact this is the same type of element that is created when a "Draw - Text" action is used for an icon directly in Touch Portal.
The element is created only the first time the script runs and gets re-used after that. Before calling its render()
method we just update the text
property value.
(Check the online scripting reference for details on available elements and all their properties.)
/// <reference path="dynamic-icons.d.ts"/>
if (!initComplete) {
// Set up persistent variables on first run.
var initComplete = true,
D2R = Math.PI / 180,
// Create a custom element to draw our text.
// We can specify all properties in the constructor's options object.
styledText = new DI.StyledText({
font: "bold 14vmin sans-serif",
width: "75%", // wrap text to 75% of icon width if it doesn't already fit
alignment: DI.Alignment.CENTER, // place text in center of image
// set fill color and shadow styling properties
style: {
fill: 'cyan',
shadow: { color: 'magenta', offset: {x: -3, y: -3} },
}
});
}
// The following code will execute every time the script runs.
// In this example we have no local variables so no need to wrap anything.
// Rotate the canvas by a random angle.
canvasContext.rotate((Math.random() * 90 - 45) * D2R, paintRectangle.center);
// Set element's current `text` property to script's arguments or a default value.
styledText.text = scriptArgs || "Hello World!";
// Call the element's `render()` method which requires the current drawing context and paint rectangle.
// All custom elements that affect the canvas directly in some way will have a `render()` method.
styledText.render(canvasContext, paintRectangle);