app/rcc/ai/skills/painter.md
The Painter widget (Pro) provides a Canvas-2D drawing surface. One per
group with widgetType: 8 (GroupWidget enum at create time). Bind code
via project.painter.setCode{groupId, code}.
Painter scripts are JavaScript-only. The Lua-first guidance in
the frame_parsers and transforms skills does NOT apply here. The
reason is concrete: painters draw via Canvas2D, which only exists as a
QJSEngine API surface. There is no Lua canvas API to expose; porting
one would be a large effort for marginal benefit. Don't try to author
painter code in Lua; it will not compile.
To pin a painter to a workspace, use widgetType: "painter"
(preferred; the back-compat integer enum is 21) on addWidget.
A painter group can be empty (zero datasets). Read peer datasets via
datasetGetFinal(uniqueId) instead of duplicating data into the painter
group.
paint(ctx, w, h): REQUIRED. Called every UI tick (60 Hz
default, configurable 1-240 via dashboard.setFps) to
redraw the canvas, whether or not new data arrived, so painters
can animate (clocks, eased camera moves) without waiting for a frame.
The function name is paint, not draw, not render. The engine
looks up globalThis.paint by name.onFrame(): optional. Called immediately before a paint that
follows new data (NOT on idle animation ticks). No arguments. This is
where you sample fresh dataset values and do per-frame computation so
paint stays cheap.onPress(x,y,button),
onDrag(x,y,dx,dy), onRelease(x,y), onMove(x,y),
onWheel(x,y,delta), onDoubleClick(x,y). Coordinates are widget
pixels; each firing repaints immediately. Mutate top-level view state
(yaw/pitch/zoom/pan) in these and read it in paint. Active on the live
dashboard and in the editor preview. See painter_js docs for details.bootstrap() does NOT exist. Top-level statements at the script's
outer scope run once when the script compiles: that is your
bootstrap.onFrame actually fires (timing reality)onFrame runs on dashboard data-update ticks, capped at the UI tick
rate (60 Hz default, configurable 1-240), NOT once per parsed frame and
NOT on idle animation ticks. If frames arrive at 1 kHz, onFrame still
fires only ~60 times per second, sampling whichever dataset values were
latest at tick time; if no data is arriving it does not fire at all, while
paint keeps running so animation continues. So:
onFrame. Doing it in paint makes you pay
the cost on every redraw, including idle redraws when no new data
arrived.onFrame. The painter will skip frames between two ticks. Move that
computation into a per-dataset transform, where every parsed
frame fires; the painter then reads the transform's output via
datasetGetFinal(uniqueId).paint. It runs against
whatever onFrame last computed.// Top-level: bootstrap once at compile time
var SAMPLES = 1024;
var ring = new Array(SAMPLES).fill(0);
var spectrum = new Array(SAMPLES / 2).fill(0);
var head = 0;
function onFrame() {
// Cheap: copy the latest sample into the ring
var v = datasetGetFinal(/*uniqueId*/ 0);
if (typeof v !== 'number') return;
ring[head] = v;
head = (head + 1) % SAMPLES;
// Run FFT on tick (60 Hz default), not on every parsed frame -- a 48 kHz
// audio source emits 48000 frames/s; we only render ~60/s, so doing
// FFT here gets us a ~2 kHz redraw rate against a recent window.
spectrum = computeFFT(ring, head);
}
function paint(ctx, w, h) {
ctx.fillStyle = theme.widget_base;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = theme.widget_highlight;
ctx.beginPath();
ctx.moveTo(0, h);
for (var i = 0; i < spectrum.length; ++i) {
var x = (i / spectrum.length) * w;
var y = h - spectrum[i] * h;
ctx.lineTo(x, y);
}
ctx.stroke();
}
Without onFrame, that FFT would run inside paint and recompute on
every redraw, including ones triggered by mouse hover and theme
changes. With it, paint only laces lines through the cached
spectrum array.
project.painter.getCode{groupId} (returns empty
string if the group has no painter set yet).project.dataset.list returns uniqueId per dataset.project.dataTable.list then
project.dataTable.get{name} for each.meta.fetchScriptingDocs{kind: "painter_js"}.scripts.list{kind: "painter"} then
scripts.get{kind: "painter", id: "<closest match>"}.assistant.script.dryRun{kind:"painter", code} verifies compile + that
paint(ctx, w, h) is defined. Doesn't render; runtime errors inside
paint only surface when the live widget mounts.assistant.script.apply{kind:"painter", groupId, code}. It
dry-runs first, then calls project.painter.setCode.datasets[i] // proxy; .length, .value, .rawValue, .title, .units,
// .uniqueId, .min, .max, .widgetMin/Max, etc.
group // .id, .title, .columns, .sourceId
frame // .number, .timestampMs
theme // ThemeManager palette; widget_base, widget_border,
// widget_text, widget_highlight, alarm, etc.
// theme.widget_colors is an array of per-channel colors.
console // log/warn/error to the editor status pane
tableGet, tableSet, datasetGetRaw, datasetGetFinal
deviceWrite(data, sourceId?) // -> {ok, error?} -- defaults to group.sourceId
actionFire(actionId) // -> {ok, error?} -- triggers a project Action
// Dashboard helpers (shared with parsers and transforms; never log)
clearPlots() // wipe plot/multiplot/FFT/GPS/3D/waterfall
setPlotPoints(n) // horizontal sample window (n >= 1)
setTerminalVisible(bool)
setNotificationLogVisible(bool)
setClockVisible(bool)
setStopwatchVisible(bool)
setActiveWorkspace(idOrName) // workspaceId (>= 1000) OR title (case-insensitive)
deviceWrite, actionFire, and the dashboard helpers: call from
onFrame(), NOT paint(). paint runs every UI tick (60 Hz default);
writing or clearPlots-ing on every tick saturates the link / yanks
the plot. Use onFrame() (once per parsed frame) or guard with state.
Don't hard-code hex. Use theme.widget_base for background,
theme.widget_border for grid/frame, theme.widget_text for labels,
theme.widget_highlight for the primary signal, theme.widget_colors[i]
for per-channel colors, theme.alarm for red/critical states. The
canvas tracks light/dark theme switches automatically.
A painter that looks technically impressive but takes the operator three seconds to read is a worse painter than a boring DataGrid. Apply these before you tune visuals:
"FAULT" vs "OK"). theme.alarm is fine for emphasis, NOT
for the sole signal.theme.widget_base, not against your assumption of
dark mode. Users flip themes. Foreground colors must read on both.
Use theme.widget_text for labels and theme.widget_highlight for
the primary signal; those are designed for the theme's background.
Hardcoded #FFFFFF text vanishes on the light theme.87 floating
alone is unreadable; 87 °C (idle 70-90) tells the operator both
the value AND whether it's normal in one glance. The dataset's
.units, .widgetMin, .widgetMax already carry this; read them
off the proxy.paint runs at 60 Hz by
default (~16 ms budget) and onFrame runs immediately before each
paint. If a single tick's onFrame + paint cost exceeds ~16 ms,
the UI feels laggy regardless of how good the visualization is. Push heavy math
into transforms (per-frame, runs on the frame thread); keep onFrame
bounded; keep paint to layout + draw against cached arrays.These are guidance, not lint rules. The Painter dryRun won't catch a color-only alarm or a 600 ms onFrame; the operator will. If you're unsure whether a design choice helps, ask the user before pushing.
The Painter renderer requires moveTo BEFORE arc to start a new
subpath. Without moveTo, the arc is implicitly connected to the
previous path endpoint and may render a stray chord:
ctx.beginPath();
ctx.moveTo(cx + r, cy); // explicit start
ctx.arc(cx, cy, r, 0, Math.PI); // arc opens cleanly
ctx.stroke();
Pen/fill: strokeStyle, fillStyle, lineWidth, lineCap, lineJoin,
miterLimit, setLineDash.
Path: beginPath, moveTo, lineTo, arc, arcTo, quadraticCurveTo,
bezierCurveTo, closePath.
Fill/stroke: fill, stroke, clearRect, fillRect, strokeRect.
Transforms: save, restore, translate, rotate, scale,
setTransform, resetTransform.
Text: font, textAlign, textBaseline, fillText, strokeText,
measureText (returns {width, actualBoundingBoxAscent, actualBoundingBoxDescent, fontBoundingBoxAscent, fontBoundingBoxDescent}),
measureTextWidth (returns just a number).
Gradients: createLinearGradient, createRadialGradient,
gradient.addColorStop.
Images/patterns: createPattern, plus drawImage / drawImageScaled
for sandbox-resolved local paths (not URLs).
NOT supported: putImageData, getImageData.
Paint runs at 60 Hz by default (configurable 1-240 via
dashboard.setFps). Cheap: lineTo, arc, fillText, arithmetic.
Avoid: JSON.stringify, allocating arrays per frame, creating new
gradients per call (cache in a top-level var).
Throwing inside paint logs a watchdog warning and the canvas keeps the
last successful frame on screen.