Back to Serial Studio

Painter widget

doc/help/Painter-Widget.md

4.0.332.5 KB
Original Source

Painter widget

The Painter is a Pro dashboard widget that exposes a JavaScript paint(ctx, w, h) callback. The script renders directly into the widget's bitmap on every dashboard tick. Use it when no built-in widget covers the visualization required by the project.

Painter is a Pro feature.

Pipeline

mermaid
flowchart LR
    A["Frame arrives"] --> B["onFrame()
(optional)"]
    B --> C["paint(ctx, w, h)"]
    C --> D["Widget bitmap"]

Each Painter widget is bound to one project group. On every dashboard tick:

  1. Serial Studio updates the group's datasets with the latest parsed values.
  2. If the script defines onFrame(), it runs once. Use it to advance time-domain state such as ring buffers, peak-hold decay, or accumulated angles.
  3. paint(ctx, w, h) runs. ctx is a Canvas2D-shaped context, w and h are the widget's pixel dimensions. The result is composited onto the dashboard.

The context is a QPainter exposed through a CanvasRenderingContext2D-style API. The widget repaints at the dashboard refresh rate (60 Hz by default, configurable from 1 to 240 Hz), independent of the data rate.

Script structure

A Painter script defines two functions: paint() (required) and onFrame() (optional).

javascript
// Minimal example: a single bar that follows datasets[0].
function paint(ctx, w, h) {
  ctx.fillStyle = "#f5f5f1";
  ctx.fillRect(0, 0, w, h);

  if (datasets.length === 0) return;

  const ds = datasets[0];
  const v  = (ds.value - ds.min) / (ds.max - ds.min || 1);

  ctx.fillStyle = "#10b981";
  ctx.fillRect(8, h - v * (h - 16) - 8, w - 16, v * (h - 16));
}

paint(ctx, w, h)

Called every UI tick. The bitmap is cleared to transparent before the call, and the path is empty. All other context state (fillStyle, strokeStyle, font, globalAlpha, transform, line styles) persists from the previous frame. Set every property on which the frame depends at the top of paint(), or wrap mid-frame changes in save() / restore() pairs.

The function should return in under 10 ms to keep up with the refresh rate. If paint() exceeds 30 ms on two consecutive ticks the widget surfaces a slow-paint warning. A 250 ms watchdog terminates the script if a single call does not return.

onFrame()

Called once per data-bearing dashboard tick, immediately before paint(). Optional. paint() can additionally run on UI-refresh ticks that carry no new data, where onFrame() is not invoked, so keep state that must advance on every tick out of onFrame() if it depends on the UI cadence. Both functions run regardless of widget visibility, so a Painter on a hidden workspace tab continues to update its state.

onFrame() is the place for time-domain bookkeeping: pushing samples into history buffers, decaying peak holds, integrating a phase angle. Keeping that logic out of paint() makes both functions easier to read and review.

javascript
const HISTORY = 256;
const trace   = [];

function onFrame() {
  if (datasets.length === 0) return;
  trace.push(datasets[0].value);
  if (trace.length > HISTORY) trace.shift();
}

function paint(ctx, w, h) {
  ctx.fillStyle = "#06140a";
  ctx.fillRect(0, 0, w, h);

  if (trace.length < 2) return;

  ctx.strokeStyle = "#22c55e";
  ctx.lineWidth   = 2;
  ctx.beginPath();
  for (let i = 0; i < trace.length; ++i) {
    const x = (i / (HISTORY - 1)) * w;
    const y = h - (trace[i] / 100) * h;
    i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  }
  ctx.stroke();
}

Persistent state

Variables declared at the top of the script (const, let, var) live for the lifetime of the widget and retain their values between calls. State is reset when the script is recompiled (after an edit in the editor), when the user switches between light and dark themes (see theme), or when the widget is destroyed (project closed, group deleted). Disconnecting and reconnecting the device does not reset state.

Globals

Four globals are available inside paint() and onFrame().

datasets

An array-like view of the group's datasets. datasets.length returns the count, datasets[i] returns the i-th dataset. Each dataset is a frozen object with the fields below. The fields are getters and always return the current value.

FieldTypeDescription
indexnumberFrame index (column position).
uniqueIdnumberStable unique ID across project edits.
titlestringDisplay name.
unitsstringMeasurement units ("degC", "rpm", ...).
widgetstringDataset widget type, if any ("bar", "gauge", "compass", "meter").
valuenumberPost-transform numeric value. NaN if the dataset is invalid.
rawValuenumberPre-transform numeric value.
textstringPost-transform string value.
rawTextstringPre-transform string value.
min, maxnumberEffective range. Falls back through widget bounds, plot bounds, then FFT bounds.
widgetMin, widgetMaxnumberWidget-specific bounds (wgtMin / wgtMax).
plotMin, plotMaxnumberPlot bounds.
fftMin, fftMaxnumberFFT bounds.
alarmLow, alarmHighnumberAlarm thresholds. Derived from alarmBands: the lower edge of the first, and the upper edge of the last, Warning-or-higher band. NaN when no such band exists.
alarmBandsarray of objectThe dataset's alarm bands: {min, max, severity, color, label, blink}. severity is 0=Info, 1=OK, 2=Warning, 3=Critical. alarmLow/alarmHigh are a legacy view of this same data.
ledHighnumberLED activation threshold.
hasPlot, hasFft, hasLedbooleanVisualization flags from the project.

datasets is implemented as a Proxy. Out-of-range indices return undefined; entries cannot be added or removed.

group

Metadata about the group bound to this Painter.

FieldTypeDescription
idnumberGroup ID.
titlestringGroup title.
columnsnumberConfigured column count for the group.
sourceIdnumberSource feeding this group (for multi-source projects).

frame

Metadata about the current dashboard tick.

FieldTypeDescription
numbernumberMonotonic frame counter, starting from 1.
timestampMsnumberWall-clock timestamp in milliseconds since epoch.

frame.timestampMs is the dashboard's reference clock. Use it for animation timing in preference to Date.now() so timestamps remain consistent across widgets.

theme

The active color palette, refreshed automatically whenever the user switches themes. Every bundled template reads its colors from theme instead of hardcoding hex values; see Composition reference for how they use it.

FieldTypeDescription
widget_basestringCanvas / card background.
widget_borderstringGrid lines, frames, and borders.
alternate_basestringCard body / recessed panel fill.
widget_textstringPrimary value labels and titles.
placeholder_textstringMuted secondary labels.
widget_highlightstringPrimary signal / value color.
accentstringSecondary accent color.
alarmstringAlarm color.
widget_colorsarray of stringPer-channel palette; index with theme.widget_colors[i % theme.widget_colors.length].

Other palette keys exist (text, window, mid, groupbox_background, groupbox_border, highlighted_text, pane_section_label, ...) mirroring the palette the QML dashboard widgets draw from, but the fields above cover what a Painter script needs. theme is a plain object, not frozen — avoid mutating it. A theme switch replaces the global theme object and forces a script recompile (see Persistent state), so any onFrame()-accumulated buffers reset when the user changes themes.

console

console.log, console.info, console.debug, console.warn, and console.error route to the Painter widget's console output, which is visible in the script editor. Each call has the same signature as the browser console.

Control APIs — deviceWrite() and actionFire()

Painter scripts can write back to the device or fire any existing project Action:

javascript
deviceWrite(data, sourceId?)     // -> { ok: true } | { ok: false, error: "..." }
actionFire(actionId)             // -> { ok: true } | { ok: false, error: "..." }

Both are synchronous, fire-and-forget, and never throw. deviceWrite defaults sourceId to the painter's group sourceId; pass an explicit one to target a different source.

Important: paint() runs on every dashboard tick (60 Hz by default). Calling deviceWrite or actionFire from paint() will saturate the link. onFrame() runs at most once per data-bearing tick (frames batched at high rates), never on UI-refresh ticks with no new data, so move the calls there and guard them on a state transition or a frame.number change:

javascript
var alarmRaised = false;
function onFrame() {
  var high = datasets[0].value > 100;
  if (high && !alarmRaised) actionFire(7);   // existing "Alarm" action
  alarmRaised = high;
}

The dashboard helpers (clearPlots(), setPlotPoints(n), setTerminalVisible(bool), setNotificationLogVisible(bool), setClockVisible(bool), setStopwatchVisible(bool), setActiveWorkspace(idOrName)) are also available, with the same { ok, error } return shape and the same "fire once on a state transition" guidance. Do not call them from paint().

See Frame Parser Scripting for full signatures and failure modes shared across parsers, transforms, and painters.

Drawing API

The context exposes a Canvas2D-style API backed by QPainter. The sections below cover the commonly used methods and properties. Additional Canvas2D members are implemented (roundRect, arcTo, ellipse, transform / setTransform / getTransform, setLineDash / getLineDash, isPointInPath, isPointInStroke, measureText, globalCompositeOperation, miterLimit, lineDashOffset, the shadow* properties, and image-smoothing controls). Calling a member that does not exist throws.

State

PropertyNotes
fillStyleColor string: hex ("#22c55e", or "#80ff0000" for 50% red — alpha byte first) and named colors. CSS rgb()/rgba() strings are not supported.
strokeStyleSame syntax as fillStyle.
lineWidthPixels.
lineCap"butt", "round", "square".
lineJoin"miter", "round", "bevel".
fontCSS font shorthand ("bold 14px monospace", "12px sans-serif").
textAlign"left" / "start", "center", "right" / "end".
textBaseline"alphabetic", "top", "middle", "bottom", "hanging".
globalAlpha0.0 to 1.0.

save() and restore() push and pop the full state stack, including the current transform.

Gradient and pattern objects are supported. createLinearGradient(x0, y0, x1, y1), createRadialGradient(x0, y0, r0, x1, y1, r1), createConicGradient(startRad, cx, cy), and createPattern(src, repetition) each return a handle that can be assigned to fillStyle or strokeStyle; gradient handles take stops via addColorStop(offset, color). fillStyle and strokeStyle also accept plain color strings. The bundled templates favor stacks of solid-color rectangles or arcs for a flat look, but a gradient is available when a smooth ramp is wanted.

Transforms

translate(x, y), rotate(radians), scale(sx, sy), resetTransform(). Rotations are in radians. Convert from degrees with * Math.PI / 180.

Paths

beginPath(), closePath(), moveTo(x, y), lineTo(x, y), rect(x, y, w, h), arc(x, y, r, startRad, endRad, counterClockwise=false), quadraticCurveTo(cpx, cpy, x, y), bezierCurveTo(c1x, c1y, c2x, c2y, x, y). Commit with fill(), stroke(), or clip().

arc() requires a preceding moveTo() to the arc's start point. The implementation maps to QPainterPath::arcTo, which connects the path's current cursor to the arc's start with an implicit line. Without the moveTo, the cursor is at the origin (0, 0) and the chord becomes part of the path. A subsequent stroke() strokes the chord, fill() encloses it, and clip() removes a wedge from the clipping region.

javascript
ctx.beginPath();
ctx.moveTo(cx + Math.cos(startA) * r, cy + Math.sin(startA) * r);
ctx.arc(cx, cy, r, startA, endA);
ctx.stroke();

For full circles, moveTo(cx + r, cy) is sufficient.

Direct shapes

fillRect(x, y, w, h), strokeRect(x, y, w, h), clearRect(x, y, w, h). These bypass the path and are appropriate for backgrounds and grids.

Text

fillText(text, x, y), strokeText(text, x, y), measureTextWidth(text), measureText(text). Honors the current font, textAlign, and textBaseline. measureTextWidth returns the advance width as a plain number; measureText returns a metrics object with width, actualBoundingBoxAscent, actualBoundingBoxDescent, fontBoundingBoxAscent, and fontBoundingBoxDescent.

Images

drawImage(src, x, y) and drawImageScaled(src, x, y, w, h). The src string is resolved through a sandbox: qrc:/ resources and paths inside the project file's directory are accepted. Other paths are rejected.

javascript
ctx.drawImage("logo.png", 12, 12);                        // relative to the project
ctx.drawImage("qrc:/icons/dashboard-large/painter.svg",   // bundled resource
              0, 0);

Adding a Painter widget to a project

  1. Open the Project Editor from the main toolbar.
  2. Click Painter in the toolbar. A new Painter group is created with a default template attached.
  3. Add datasets to the group. The script accesses them through the datasets global.
  4. Select the group in the tree and click Edit Code to open the script editor.
  5. Optionally click Template to load a built-in template. Edits compile and reload live.

Built-in templates

Eighteen templates are bundled with Serial Studio. Most use the card layout described in Composition reference. The instrument-style templates (oscilloscope, radar sweep, artificial horizon) use a recessed instrument-panel layout instead, drawn through the same theme global as every other template.

TemplateDatasets neededWhat it draws
Default template3 (X, Y, Z)Position indicator with a planar dot and a Z bar.
Audio VU meter1+Per-channel VU bars with peak markers, any channel count.
Bars with peak hold1+Vertical bars with decaying peak-hold lines.
Clock face0-1 (seconds)Analog clock driven by the dataset value; falls back to wall-clock time when the dataset is absent.
Dial gauge1Single arc gauge with a needle.
HeatmapNColor-mapped grid, one cell per dataset.
Artificial horizon2 (pitch, roll)Aviation attitude indicator.
LED matrixNDiscrete LED grid driven by per-row values.
Oscilloscope1+Phosphor-style traces with a CRT background.
Polar plot2N (angle, mag)Multi-trace polar coordinate plot.
Progress rings1+Concentric ring gauges.
Radar sweep2N (az, range)Sweeping radar PPI with target blips.
7-segment display1+Segmented numeric readout.
Sparkline gridNOne mini line chart per dataset.
Status grid1+Tile grid with values and color-coded states.
Strip chart1+Multi-trace rolling line chart.
Vector field2N (Vx, Vy)Vector arrows on a grid.
XY scope (Lissajous)2 per pairXY mode oscilloscope.

Templates are stored as plain .js files under app/rcc/scripts/painter/.

Composition reference

The bundled templates use a shared visual layout and draw every color through the theme global. None of this is enforced by the engine; the patterns are documented here so user-authored Painters can match the built-in style when desired. Hardcoding a hex color instead of reading theme.* renders correctly once, then stays wrong when the user switches between light and dark themes.

Background and card

javascript
ctx.fillStyle = theme.widget_base;
ctx.fillRect(0, 0, w, h);

A card with a soft shadow and a 1-pixel border holds the content:

javascript
const pad = 14;
ctx.fillStyle = theme.widget_border;      // shadow
ctx.fillRect(pad + 1, pad + 2, w - pad * 2, h - pad * 2);
ctx.fillStyle = theme.alternate_base;     // card body
ctx.fillRect(pad, pad, w - pad * 2, h - pad * 2);
ctx.strokeStyle = theme.widget_border;
ctx.lineWidth = 1;
ctx.strokeRect(pad + 0.5, pad + 0.5, w - pad * 2 - 1, h - pad * 2 - 1);

The 0.5-pixel offset on strokeRect aligns the stroke to a single pixel column. See "Lines look fuzzy" under Common errors.

Header strip

A header consists of a left-aligned bold title, an optional right-aligned secondary label, and a 1-pixel rule:

javascript
ctx.fillStyle = theme.widget_text;
ctx.font = "bold 11px sans-serif";
ctx.textAlign = "start";
ctx.fillText("LEVEL  METER", pad + 16, headerY + 4);

ctx.fillStyle = theme.placeholder_text;
ctx.font = "9px sans-serif";
ctx.textAlign = "end";
ctx.fillText(datasets.length === 1 ? datasets[0].title : "MULTI",
             w - pad - 16, headerY + 4);

ctx.fillStyle = theme.widget_border;
ctx.fillRect(pad + 12, headerY + 12, w - (pad + 12) * 2, 1);

The double space in "LEVEL METER" widens the inter-letter spacing without requiring a font change. The right-aligned label falls back to a fixed tag when the template draws more than one dataset in a single meter.

Segmented bars

The templates draw bars as a sequence of solid-color segments rather than a gradient fill. Each segment is rendered in one of two states (lit or unlit) so the full range of the bar remains visible:

javascript
const SEGMENTS = 32;
const SEG_GAP  = 2;
const segW = (w - SEG_GAP * (SEGMENTS - 1)) / SEGMENTS;
for (let i = 0; i < SEGMENTS; ++i) {
  const t   = (i + 0.5) / SEGMENTS;
  const lit = t <= level;
  ctx.fillStyle = lit ? lit_color(t) : unlit_color(t);
  ctx.fillRect(x + i * (segW + SEG_GAP), y, segW, h);
}

lit_color(t) and unlit_color(t) pick from theme.widget_highlight, theme.accent, and theme.alarm by zone, and from theme.widget_border when unlit. The dial gauge and progress rings templates apply the same approach to colored arc zones.

Peak hold marker

A halo in the border color with a solid core in the accent color gives a peak indicator that remains visible across light and dark themes:

javascript
const px = x + w * peak;
ctx.fillStyle = theme.widget_border;      // halo
ctx.fillRect(px - 2, y - 2, 1, h + 4);
ctx.fillRect(px + 2, y - 2, 1, h + 4);
ctx.fillStyle = theme.accent;             // core
ctx.fillRect(px - 1, y - 3, 2, h + 6);

Typography

Three font roles cover most layouts:

RoleFont specColor
Card header"bold 11px sans-serif"theme.widget_text
Body label"10px sans-serif"theme.placeholder_text
Numeric value"bold 18px sans-serif"theme.widget_text

To place a value and a unit label side by side, measure the value with the value's own font:

javascript
ctx.font = "bold 18px sans-serif";
const valueW = ctx.measureTextWidth(value);
ctx.fillText(value, x, y);

ctx.fillStyle = theme.placeholder_text;
ctx.font      = "10px sans-serif";
ctx.fillText(units, x + valueW + 6, y);

For centered text, prefer textAlign over a measured offset:

javascript
ctx.textAlign = "center";
ctx.fillText(label, cx, y);

Light and dark themes

Every bundled template, including the instrument-style ones (oscilloscope, radar_sweep, horizon), draws through the same theme object; none of them hardcode a light or dark palette. The "instrument panel" look on those three comes from the composition — a recessed screen with a scan grid inside the same card frame — not from a separate color scheme, so the panel repaints correctly in whichever theme the user has active. A Painter that reads theme.* gets the same behavior for free.

Sizing

Compute the available area first, fit content into the smaller dimension, and center horizontally. Hard-coded fractional positions like cy = h * 0.55 look correct at one aspect ratio and break at others.

javascript
const labelH = 36;                          // reserved for bottom labels
const margin = 12;
const availW = w - margin * 2;
const availH = h - margin * 2 - labelH;
const r      = Math.max(8, Math.min(availW, availH) * 0.5 - 8);
const cx     = w / 2;
const cy     = margin + r + 8;

Examples

Sparkline

A 60-sample rolling trace with a filled area and a marker at the latest sample.

javascript
const HISTORY = 60;
const trace   = [];

function onFrame() {
  if (datasets.length === 0) return;
  const v = datasets[0].value;
  if (Number.isFinite(v)) {
    trace.push(v);
    if (trace.length > HISTORY) trace.shift();
  }
}

function paint(ctx, w, h) {
  ctx.fillStyle = theme.widget_base;
  ctx.fillRect(0, 0, w, h);

  if (trace.length < 2) return;

  const ds    = datasets[0];
  const span  = (ds.max - ds.min) || 1;
  const color = theme.widget_highlight;

  // Filled area under the line.
  ctx.fillStyle   = color;
  ctx.globalAlpha = 0.18;
  ctx.beginPath();
  ctx.moveTo(0, h);
  for (let i = 0; i < trace.length; ++i) {
    const x = (i / (HISTORY - 1)) * w;
    const n = (trace[i] - ds.min) / span;
    const y = h - Math.max(0, Math.min(1, n)) * h;
    ctx.lineTo(x, y);
  }
  ctx.lineTo(((trace.length - 1) / (HISTORY - 1)) * w, h);
  ctx.closePath();
  ctx.fill();
  ctx.globalAlpha = 1;

  // Line on top of the fill.
  ctx.strokeStyle = color;
  ctx.lineWidth   = 1.5;
  ctx.beginPath();
  for (let i = 0; i < trace.length; ++i) {
    const x = (i / (HISTORY - 1)) * w;
    const n = (trace[i] - ds.min) / span;
    const y = h - Math.max(0, Math.min(1, n)) * h;
    if (i === 0) ctx.moveTo(x, y);
    else         ctx.lineTo(x, y);
  }
  ctx.stroke();
}

Tank level with alarm coloring

Renders one dataset as a segmented vertical fill. The fill color switches through green, amber, and red based on the dataset's alarm thresholds.

javascript
function paint(ctx, w, h) {
  ctx.fillStyle = theme.widget_base;
  ctx.fillRect(0, 0, w, h);

  if (datasets.length === 0) return;

  const ds   = datasets[0];
  const v    = Number.isFinite(ds.value) ? ds.value : ds.min;
  const span = (ds.max - ds.min) || 1;
  const norm = Math.max(0, Math.min(1, (v - ds.min) / span));

  // Card with header.
  const pad = 16;
  ctx.fillStyle = theme.alternate_base;
  ctx.fillRect(pad, pad + 22, w - pad * 2, h - pad * 2 - 22);
  ctx.strokeStyle = theme.widget_border;
  ctx.lineWidth = 1;
  ctx.strokeRect(pad + 0.5, pad + 22 + 0.5, w - pad * 2 - 1, h - pad * 2 - 23);

  ctx.fillStyle = theme.widget_text;
  ctx.font = "bold 11px sans-serif";
  ctx.textAlign = "start";
  ctx.fillText("TANK  LEVEL", pad, 18);
  ctx.fillStyle = theme.placeholder_text;
  ctx.font = "9px sans-serif";
  ctx.textAlign = "end";
  ctx.fillText(ds.units || "", w - pad, 18);

  // Tank body.
  const tx = pad + 24;
  const ty = pad + 36;
  const tw = w - pad * 2 - 48;
  const th = h - ty - pad - 28;

  ctx.fillStyle = theme.alternate_base;
  ctx.fillRect(tx, ty, tw, th);

  // Color selection based on alarms.
  let color = theme.widget_highlight;
  if (Number.isFinite(ds.alarmHigh) && v >= ds.alarmHigh) color = theme.alarm;
  else if (norm > 0.85) color = theme.accent;

  // Segmented fill, bottom-up.
  const SLICES = 20;
  const sliceH = (th - 4) / SLICES;
  const filled = Math.round(norm * SLICES);
  for (let i = 0; i < filled; ++i) {
    ctx.fillStyle = color;
    ctx.fillRect(tx + 2, ty + th - (i + 1) * sliceH - 2, tw - 4, sliceH - 1);
  }

  ctx.strokeStyle = theme.widget_border;
  ctx.lineWidth = 1;
  ctx.strokeRect(tx + 0.5, ty + 0.5, tw - 1, th - 1);

  // Numeric readout below the tank.
  ctx.fillStyle = theme.widget_text;
  ctx.font = "bold 16px sans-serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "alphabetic";
  ctx.fillText(v.toFixed(1) + " " + (ds.units || ""), w / 2, h - 10);

  ctx.textAlign = "start";
}

Polar bug indicator

Two datasets, bearing in degrees and range in 0..1, plotted as a marker on a polar grid. Each arc() is preceded by a moveTo() to its start point.

javascript
function paint(ctx, w, h) {
  ctx.fillStyle = theme.widget_base;
  ctx.fillRect(0, 0, w, h);

  if (datasets.length < 2) return;

  const cx = w / 2;
  const cy = h / 2;
  const r  = Math.min(w, h) * 0.42;

  // Concentric range rings.
  ctx.strokeStyle = theme.widget_border;
  ctx.lineWidth   = 1;
  for (let i = 1; i <= 4; ++i) {
    const rr = (r * i) / 4;
    ctx.beginPath();
    ctx.moveTo(cx + rr, cy);
    ctx.arc(cx, cy, rr, 0, Math.PI * 2);
    ctx.stroke();
  }

  // Bearing spokes every 30 degrees.
  ctx.strokeStyle = theme.widget_border;
  for (let deg = 0; deg < 360; deg += 30) {
    const a = (deg - 90) * Math.PI / 180;
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
    ctx.stroke();
  }

  // Marker.
  const bearing = datasets[0].value;
  const range   = Math.max(0, Math.min(1, datasets[1].value));
  const a       = (bearing - 90) * Math.PI / 180;
  const x       = cx + Math.cos(a) * r * range;
  const y       = cy + Math.sin(a) * r * range;

  ctx.fillStyle = theme.widget_highlight;
  ctx.beginPath();
  ctx.moveTo(x + 6, y);
  ctx.arc(x, y, 6, 0, Math.PI * 2);
  ctx.fill();

  // Readout.
  ctx.fillStyle    = theme.widget_text;
  ctx.font         = "bold 12px sans-serif";
  ctx.textAlign    = "center";
  ctx.textBaseline = "alphabetic";
  ctx.fillText(bearing.toFixed(0) + "°  /  " + (range * 100).toFixed(0) + "%",
               cx, h - 10);
  ctx.textAlign = "start";
}

Performance

The Painter pipeline targets the dashboard refresh rate (60 Hz by default) for moderately complex scenes: a few hundred line segments, a few hundred filled shapes, and on the order of ten text labels per frame.

Common causes of slow paints:

  • Allocation in the paint path. Iterating with for (const ds of datasets) is fine. datasets.map(d => d.value) allocates a new array each frame. So does ds.title.toUpperCase() inside a tight loop. Move the work into onFrame() and cache the result if the cost is non-trivial.
  • measureTextWidth() per label. Each call is a real metrics call into Qt. Measure fixed labels once at compile time and cache the width.
  • One stroke() per point. A single beginPath() followed by many lineTo() calls and one stroke() is one QPainter call. A beginPath(), lineTo(), stroke() per point is N calls.
  • drawImage from disk every frame. drawImage reloads the image from disk on every call with no internal caching, so per-frame disk reads are not free. Pre-resolve to qrc:/ paths or read the image once at the top of the script and reuse it.

The 250 ms watchdog terminates the script if a single paint() or onFrame() call exceeds it. The 30 ms slow-paint warning fires after two consecutive ticks over budget.

Common errors

ReferenceError: datasets is not defined

The bridge globals are always defined when the script runs. If this error appears, the engine is failing to bootstrap. Check the script editor's status bar for a bootstrap: message and report the issue if no message is shown.

Widget renders once and then freezes

paint() or onFrame() is throwing an exception, which sets runtimeOk to false. The error message is shown next to the widget. Common causes: indexing datasets[0] when the group is empty; dividing by (ds.max - ds.min) when both are zero; calling .toFixed() on undefined.

A diagonal line crosses the arc

arc() does not start a new subpath. Immediately after beginPath() the path cursor is at the implicit origin (0, 0); the first arc() call connects that origin to the arc's start with an implicit lineTo. A subsequent stroke() strokes the chord; fill() encloses it; clip() removes a wedge from the clipping region.

Add a moveTo() to the arc's start point:

javascript
ctx.beginPath();
ctx.moveTo(cx + Math.cos(startA) * r, cy + Math.sin(startA) * r);
ctx.arc(cx, cy, r, startA, endA);
ctx.stroke();

For full circles, moveTo(cx + r, cy) is sufficient.

Gradient or pattern fill shows nothing

createLinearGradient, createRadialGradient, createConicGradient, and createPattern return handles that must be assigned to fillStyle or strokeStyle before drawing, and a gradient needs at least two addColorStop() entries to render visible color. A pattern whose src falls outside the image sandbox resolves to an empty tile. The bundled audio meter, dial gauge, and progress rings templates avoid gradients entirely and stack solid-color rectangles or arcs instead.

Measuring text width

measureTextWidth(text) returns the advance width as a number directly:

javascript
const w = ctx.measureTextWidth("hello");

measureText(text) is also available and returns a metrics object with width, actualBoundingBoxAscent, actualBoundingBoxDescent, fontBoundingBoxAscent, and fontBoundingBoxDescent. For centered text, prefer ctx.textAlign = "center" over measuring.

Lines look fuzzy

Canvas2D pixel coordinates address the boundaries between pixels. A 1-pixel line at integer Y is split between two rows and rendered as a 2-pixel anti-aliased band. Offset by 0.5 (ctx.moveTo(x, y + 0.5)) or set ctx.lineWidth = 1 and round coordinates.

Colors do not match what was set

Context state (fillStyle, strokeStyle, globalAlpha, transform, line styles) carries over between paint() calls. If a previous frame ended with globalAlpha = 0.5, the next frame starts with the same value. Set every state property the frame depends on at the top of paint(), or wrap mid-frame changes in matched save() / restore() pairs. Bitmap pixels are wiped between frames; context state is not.

State leaks between channels

The script has a single global scope per Painter widget, not per dataset. A top-level let trace = [] is one buffer shared across all datasets. Use an array indexed by dataset index instead:

javascript
const traces = [];
function onFrame() {
  while (traces.length < datasets.length) traces.push([]);
  for (let i = 0; i < datasets.length; ++i) {
    traces[i].push(datasets[i].value);
  }
}

drawImage shows nothing

The image path resolver accepts qrc:/ resources and paths inside the project file's directory. Other paths are rejected, and a rejected path is silently skipped: nothing is drawn and no exception is raised. Confirm the resource path or that the file resolves under the project directory.

Recommendations

  • Start from a built-in template and modify it. The eighteen bundled scripts cover most common shapes.
  • Keep paint() free of per-tick state mutation. Move bookkeeping into onFrame() so paint() is a function of the current state.
  • Use frame.timestampMs for animation timing instead of Date.now().
  • If a built-in widget covers the visualization, use the built-in widget. The Painter is appropriate when no other widget fits.
  • A console.log called once per paint() emits one line per dashboard tick (60 lines per second at the default refresh rate). Useful during development; remove from shipped projects.
  • For scripts longer than around 100 lines, split rendering into named helpers (drawGrid, drawTraces, drawLegend).

See also