diff --git a/companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts b/companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts index 551a23e8a0..44966b2754 100644 --- a/companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts +++ b/companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts @@ -119,6 +119,62 @@ export function CreateElementOfType(type: SomeButtonGraphicsElement['type']): So borderPosition: { value: 'center', isExpression: false }, borderOnlyArc: { value: false, isExpression: false }, } + case 'gauge': + return { + id: nanoid(), + name: 'Gauge', + usage: ButtonGraphicsElementUsage.Automatic, + type: 'gauge', + enabled: { value: true, isExpression: false }, + opacity: { value: 100, isExpression: false }, + x: { value: 0, isExpression: false }, + y: { value: 0, isExpression: false }, + width: { value: 100, isExpression: false }, + height: { value: 100, isExpression: false }, + rotation: { value: 0, isExpression: false }, + value: { value: 0, isExpression: false }, + min: { value: 0, isExpression: false }, + max: { value: 100, isExpression: false }, + origin: { value: 0, isExpression: false }, + symmetric: { value: false, isExpression: false }, + orientation: { value: 'horizontal', isExpression: false }, + reverse: { value: false, isExpression: false }, + trackWidth: { value: 100, isExpression: false }, + startAngle: { value: 0, isExpression: false }, + endAngle: { value: 360, isExpression: false }, + ringWidth: { value: 20, isExpression: false }, + roundedEnds: { value: true, isExpression: false }, + fillEnabled: { value: true, isExpression: false }, + multiColour: { value: true, isExpression: false }, + stops: { + value: [ + { + _id: { value: nanoid(), isExpression: false }, + value: { value: 0, isExpression: false }, + color: { value: 0x00ff00, isExpression: false }, + gradient: { value: false, isExpression: false }, + }, + { + _id: { value: nanoid(), isExpression: false }, + value: { value: 66, isExpression: false }, + color: { value: 0xffff00, isExpression: false }, + gradient: { value: false, isExpression: false }, + }, + { + _id: { value: nanoid(), isExpression: false }, + value: { value: 85, isExpression: false }, + color: { value: 0xff0000, isExpression: false }, + gradient: { value: false, isExpression: false }, + }, + ], + isExpression: false, + }, + markerEnabled: { value: false, isExpression: false }, + markerColor: { value: 0xffffff, isExpression: false }, + markerWidth: { value: 15, isExpression: false }, + trackStyle: { value: 'transparent', isExpression: false }, + trackAmount: { value: 70, isExpression: false }, + } case 'composite': // Composite elements should not be created directly through this function // They are created with custom logic in layeredStyleAddElement diff --git a/companion/lib/Graphics/ConvertGraphicsElements.ts b/companion/lib/Graphics/ConvertGraphicsElements.ts index 277183ca99..875d7f139d 100644 --- a/companion/lib/Graphics/ConvertGraphicsElements.ts +++ b/companion/lib/Graphics/ConvertGraphicsElements.ts @@ -1,7 +1,10 @@ import { createHash } from 'node:crypto' import type { JsonValue } from 'type-fest' import { formatLocation } from '@companion-app/shared/ControlId.js' -import { FONTSIZE_SHRINK_DEFAULT } from '@companion-app/shared/Graphics/ElementPropertiesSchemas.js' +import { + FONTSIZE_SHRINK_DEFAULT, + getElementSchemaProperty, +} from '@companion-app/shared/Graphics/ElementPropertiesSchemas.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' import type { ExpressionOrValue } from '@companion-app/shared/Model/Options.js' import type { @@ -17,6 +20,8 @@ import type { ButtonGraphicsDrawBorder, ButtonGraphicsDrawBounds, ButtonGraphicsElementBase, + ButtonGraphicsGaugeDrawElement, + ButtonGraphicsGaugeElement, ButtonGraphicsGroupDrawElement, ButtonGraphicsGroupElement, ButtonGraphicsImageDrawElement, @@ -61,6 +66,26 @@ import { collectContentHashes, computeElementContentHash } from './ConvertGraphi import type { ElementConversionCache, ElementConversionCacheEntry } from './ElementConversionCache.js' import type { ImageResult } from './ImageResult.js' +/** Extract the valid choice IDs for a dropdown field directly from the element schema. */ +function dropdownChoices( + elementType: Parameters[0], + fieldId: string +): T[] { + const field = getElementSchemaProperty(elementType, fieldId) + if (field?.type !== 'dropdown') return [] + return field.choices.map((c) => String(c.id)) as T[] +} + +// All derived once at module load to avoid repeated schema lookups on every render. +const CANVAS_DECORATION_CHOICES = dropdownChoices('canvas', 'decoration') +const CANVAS_SHOW_STATUS_ICONS_CHOICES = dropdownChoices('canvas', 'showStatusIcons') +const IMAGE_FILL_MODE_CHOICES = dropdownChoices('image', 'fillMode') +const TEXT_FONT_CHOICES = dropdownChoices('text', 'font') +// borderPosition is shared across box/line/circle — all use the same choices from borderFields. +const BORDER_POSITION_CHOICES = dropdownChoices('box', 'borderPosition') +const GAUGE_ORIENTATION_CHOICES = dropdownChoices('gauge', 'orientation') +const GAUGE_TRACK_STYLE_CHOICES = dropdownChoices('gauge', 'trackStyle') + export async function ConvertSomeButtonGraphicsElementForDrawing( compositeElementStore: InstanceDefinitions, parser: VariablesAndExpressionParser, @@ -164,6 +189,10 @@ async function convertElements( cacheEntry = convertCircleElementForDrawing(context, element, idPrefix) break } + case 'gauge': { + cacheEntry = convertGaugeElementForDrawing(context, element, idPrefix) + break + } case 'composite': { cacheEntry = await convertCompositeElementForDrawing(context, element, idPrefix) break @@ -239,14 +268,10 @@ function convertCanvasElementForDrawing( type: 'canvas', usage: element.usage, // color, - decoration: helper.getEnum( - 'decoration', - Object.values(ButtonGraphicsDecorationType), - ButtonGraphicsDecorationType.FollowDefault - ), + decoration: helper.getEnum('decoration', CANVAS_DECORATION_CHOICES, ButtonGraphicsDecorationType.FollowDefault), showStatusIcons: helper.getEnum( 'showStatusIcons', - Object.values(ButtonGraphicsShowStatusIcons), + CANVAS_SHOW_STATUS_ICONS_CHOICES, ButtonGraphicsShowStatusIcons.FollowDefault ), contentHash: '', // Will be computed below @@ -640,7 +665,7 @@ async function convertImageElementForDrawing( base64Image: base64Image, halign: helper.getHorizontalAlignment('halign'), valign: helper.getVerticalAlignment('valign'), - fillMode: helper.getEnum('fillMode', ['crop', 'fill', 'fit'], 'fit'), + fillMode: helper.getEnum('fillMode', IMAGE_FILL_MODE_CHOICES, 'fit'), contentHash: '', // Will be computed below } @@ -670,7 +695,7 @@ function convertTextElementForDrawing( text: helper.getParsedString('text', 'ERR') + '', fontsize: helper.getNumber('fontsize', FONTSIZE_SHRINK_DEFAULT), fontsizeAllowShrink: helper.getBoolean('fontsizeAllowShrink', false), - font: helper.getEnum('font', ['companion-sans', 'companion-mono'], 'companion-sans'), + font: helper.getEnum('font', TEXT_FONT_CHOICES, 'companion-sans'), color: helper.getNumber('color', 0), halign: helper.getHorizontalAlignment('halign'), valign: helper.getVerticalAlignment('valign'), @@ -783,13 +808,73 @@ function convertCircleElementForDrawing( return { drawElement, usedVariables, compositeElement: null } } +function convertGaugeElementForDrawing( + context: ParseElementsContext, + element: ButtonGraphicsGaugeElement, + idPrefix: string +): ElementConversionCacheEntry { + const { helper, usedVariables } = context.createHelper(element) + + const enabled = helper.getBoolean('enabled', true) + if (!enabled && context.onlyEnabled) return { drawElement: null, usedVariables, compositeElement: null } + + // Colour stops carry values in the authored Min..Max domain; the renderer normalises them to + // track positions. Values are intentionally not clamped here so the renderer can map them. + const stopsRaw = (element.stops as ExpressionOrValue).value + const stops: ButtonGraphicsGaugeDrawElement['stops'] = Array.isArray(stopsRaw) + ? stopsRaw.map((row) => { + const rowHelper = helper.forRow(row) + return { + value: rowHelper.getNumber('value', 0), + color: rowHelper.getNumber('color', 0), + gradient: rowHelper.getBoolean('gradient', false), + } + }) + : [] + + const drawElement: ButtonGraphicsGaugeDrawElement = { + id: idPrefix + element.id, + type: 'gauge', + usage: element.usage, + enabled, + opacity: helper.getNumber('opacity', 1, 0.01), + ...convertDrawBounds(helper), + rotation: helper.getNumber('rotation', 0), + // Value-mapping fields are kept in the authored domain; the renderer normalises to 0–100. + value: helper.getNumber('value', 0), + min: helper.getNumber('min', 0), + max: helper.getNumber('max', 100), + origin: helper.getNumber('origin', 0), + symmetric: helper.getBoolean('symmetric', false), + orientation: helper.getTolerantEnum('orientation', GAUGE_ORIENTATION_CHOICES, 'horizontal'), + reverse: helper.getBoolean('reverse', false), + trackWidth: Math.max(0, Math.min(100, helper.getNumber('trackWidth', 100))), + startAngle: helper.getNumber('startAngle', 0), + endAngle: helper.getNumber('endAngle', 360), + ringWidth: Math.max(1, Math.min(50, helper.getNumber('ringWidth', 20))), + roundedEnds: helper.getBoolean('roundedEnds', true), + fillEnabled: helper.getBoolean('fillEnabled', true), + multiColour: helper.getBoolean('multiColour', true), + stops: stops, + markerEnabled: helper.getBoolean('markerEnabled', false), + markerColor: helper.getNumber('markerColor', 0xffffff), + markerWidth: Math.max(1, Math.min(100, helper.getNumber('markerWidth', 15))), + trackStyle: helper.getTolerantEnum('trackStyle', GAUGE_TRACK_STYLE_CHOICES, 'transparent'), + trackAmount: Math.max(0, Math.min(100, helper.getNumber('trackAmount', 70))), + contentHash: '', + } + + drawElement.contentHash = computeElementContentHash(drawElement) + return { drawElement, usedVariables, compositeElement: null } +} + function convertBorderProperties( helper: ElementExpressionHelper ): ButtonGraphicsDrawBorder { return { borderWidth: helper.getNumber('borderWidth', 0, 0.01), borderColor: helper.getNumber('borderColor', 0), - borderPosition: helper.getEnum('borderPosition', ['inside', 'center', 'outside'], 'inside'), + borderPosition: helper.getEnum('borderPosition', BORDER_POSITION_CHOICES, 'inside'), } } diff --git a/companion/lib/Graphics/ConvertGraphicsElements/Helper.ts b/companion/lib/Graphics/ConvertGraphicsElements/Helper.ts index edadc676d6..50cd80c56d 100644 --- a/companion/lib/Graphics/ConvertGraphicsElements/Helper.ts +++ b/companion/lib/Graphics/ConvertGraphicsElements/Helper.ts @@ -171,13 +171,23 @@ export class ElementExpressionHelper { const trimmed = String(raw ?? '') .trim() .toLowerCase() + // An empty/whitespace-only input has no first character to match against, so + // `startsWith(undefined)` would coerce to `startsWith('undefined')` and never match. + // Fall back to the default explicitly instead. + if (trimmed.length === 0) return defaultValue return values.find((v) => v.toLowerCase().startsWith(trimmed[0])) ?? defaultValue } getBoolean(propertyName: keyof T, defaultValue: boolean): boolean { const value = this.#getValue(propertyName) - if (!value.isExpression) return Boolean(value.value) + if (!value.isExpression) { + // A missing property (added to the schema after an element was saved) surfaces as + // `undefined` and must fall back to the default rather than coercing to `false`. + // An explicit `null`/`0`/`''` is still treated as falsy. + if (value.value === undefined) return defaultValue + return Boolean(value.value) + } const result = this.executeExpressionAndTrackVariables(value.value, 'boolean') if (!result.ok) { diff --git a/companion/test/Graphics/ConvertGraphicsElements.test.ts b/companion/test/Graphics/ConvertGraphicsElements.test.ts index 6a679af2b0..f039dcf8df 100644 --- a/companion/test/Graphics/ConvertGraphicsElements.test.ts +++ b/companion/test/Graphics/ConvertGraphicsElements.test.ts @@ -6,6 +6,8 @@ import type { ButtonGraphicsBoxDrawElement, ButtonGraphicsBoxElement, ButtonGraphicsCanvasDrawElement, + ButtonGraphicsGaugeDrawElement, + ButtonGraphicsGaugeElement, ButtonGraphicsGroupDrawElement, ButtonGraphicsGroupElement, ButtonGraphicsImageElement, @@ -185,6 +187,47 @@ function makeGroupEl( } } +function makeGaugeEl(overrides: Partial = {}): ButtonGraphicsGaugeElement { + return { + id: 'gauge1', + name: '', + type: 'gauge', + usage: USAGE, + enabled: val(true), + opacity: val(100), + x: val(0), + y: val(0), + width: val(100), + height: val(100), + rotation: val(0), + value: val(50), + min: val(0), + max: val(100), + origin: val(0), + symmetric: val(false), + orientation: val('horizontal'), + reverse: val(false), + trackWidth: val(100), + startAngle: val(0), + endAngle: val(360), + ringWidth: val(20), + roundedEnds: val(true), + fillEnabled: val(true), + multiColour: val(true), + stops: val([ + { value: 0, color: 0x00ff00, gradient: false }, + { value: 66, color: 0xffff00, gradient: false }, + { value: 85, color: 0xff0000, gradient: false }, + ]), + markerEnabled: val(false), + markerColor: val(0xffffff), + markerWidth: val(15), + trackStyle: val('transparent'), + trackAmount: val(70), + ...overrides, + } +} + function makeReferenceEl(overrides: Partial = {}): ButtonGraphicsReferenceElement { return { id: 'ref1', @@ -2360,4 +2403,246 @@ describe('ConvertSomeButtonGraphicsElementForDrawing', () => { expect(result.cyclicLocations.size).toBe(1) }) }) + + describe('gauge element conversion', () => { + async function convertGauge( + element: ButtonGraphicsGaugeElement, + variableValues: Record> = {}, + onlyEnabled = true + ) { + return ConvertSomeButtonGraphicsElementForDrawing( + createMockInstanceDefinitions(), + createMockParser(variableValues), + mockDrawPixelBuffers, + [element], + new Map(), + onlyEnabled, + null, + null, + null + ) + } + + function gaugeDrawEl(result: Awaited>): ButtonGraphicsGaugeDrawElement { + return result.elements[0] as ButtonGraphicsGaugeDrawElement + } + + test('converts gauge element with all defaults', async () => { + const result = await convertGauge(makeGaugeEl()) + const el = gaugeDrawEl(result) + expect(el.type).toBe('gauge') + expect(el.id).toBe('gauge1') + expect(el.value).toBe(50) + expect(el.min).toBe(0) + expect(el.max).toBe(100) + expect(el.origin).toBe(0) + expect(el.symmetric).toBe(false) + expect(el.orientation).toBe('horizontal') + expect(el.reverse).toBe(false) + expect(el.trackWidth).toBe(100) + expect(el.startAngle).toBe(0) + expect(el.endAngle).toBe(360) + expect(el.ringWidth).toBe(20) + expect(el.roundedEnds).toBe(true) + expect(el.fillEnabled).toBe(true) + expect(el.multiColour).toBe(true) + expect(el.markerEnabled).toBe(false) + expect(el.markerColor).toBe(0xffffff) + expect(el.markerWidth).toBe(15) + expect(el.trackStyle).toBe('transparent') + expect(el.trackAmount).toBe(70) + expect(el.opacity).toBe(1) + }) + + test('value, min, max, origin pass through in the authored domain (no clamp/round)', async () => { + // Values are now mapped by the renderer; the converter keeps them raw. + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ value: val(150) }))).value).toBe(150) + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ value: val(-10) }))).value).toBe(-10) + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ value: val(33.333) }))).value).toBe(33.333) + const el = gaugeDrawEl(await convertGauge(makeGaugeEl({ min: val(-232), max: val(24), origin: val(-100) }))) + expect(el.min).toBe(-232) + expect(el.max).toBe(24) + expect(el.origin).toBe(-100) + }) + + test('value resolved from expression', async () => { + const el = gaugeDrawEl( + await convertGauge(makeGaugeEl({ value: expr('$(counter:level)') }), { counter: { level: 75 } }) + ) + expect(el.value).toBe(75) + }) + + test('symmetric / fillEnabled / multiColour booleans pass through', async () => { + const el = gaugeDrawEl( + await convertGauge(makeGaugeEl({ symmetric: val(true), fillEnabled: val(false), multiColour: val(false) })) + ) + expect(el.symmetric).toBe(true) + expect(el.fillEnabled).toBe(false) + expect(el.multiColour).toBe(false) + }) + + test('missing boolean field falls back to its schema default', async () => { + // Regression: a boolean added to the schema after an element was saved must use the default, + // not coerce undefined to false (which previously disabled the fill on existing gauges). + const el = makeGaugeEl() + delete (el as Partial).fillEnabled + expect(gaugeDrawEl(await convertGauge(el)).fillEnabled).toBe(true) + }) + + test('orientation tolerant matching: leading whitespace and prefix', async () => { + // Deliberately out-of-spec raw strings exercising the tolerant parser — cast to bypass union check + expect( + gaugeDrawEl(await convertGauge(makeGaugeEl({ orientation: val(' horizontal') as any }))).orientation + ).toBe('horizontal') + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ orientation: val('v') as any }))).orientation).toBe( + 'vertical' + ) + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ orientation: val('r') as any }))).orientation).toBe('ring') + }) + + test('orientation: unknown value falls back to default', async () => { + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ orientation: val('diagonal') as any }))).orientation).toBe( + 'horizontal' + ) + }) + + test('orientation: empty string falls back to default', async () => { + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ orientation: val(' ') as any }))).orientation).toBe( + 'horizontal' + ) + }) + + test('all three orientations pass through', async () => { + for (const o of ['horizontal', 'vertical', 'ring'] as const) { + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ orientation: val(o) }))).orientation).toBe(o) + } + }) + + test('trackStyle tolerant matching', async () => { + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ trackStyle: val(' transparent') as any }))).trackStyle).toBe( + 'transparent' + ) + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ trackStyle: val('d') as any }))).trackStyle).toBe('dimmed') + }) + + test('ringWidth clamped to 1–50', async () => { + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ ringWidth: val(80) }))).ringWidth).toBe(50) + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ ringWidth: val(0) }))).ringWidth).toBe(1) + }) + + test('trackWidth clamped to 0–100', async () => { + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ trackWidth: val(150) }))).trackWidth).toBe(100) + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ trackWidth: val(-5) }))).trackWidth).toBe(0) + }) + + test('trackAmount clamped to 0–100', async () => { + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ trackAmount: val(200) }))).trackAmount).toBe(100) + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ trackAmount: val(-5) }))).trackAmount).toBe(0) + }) + + test('markerWidth clamped to 1–100, marker fields pass through', async () => { + const el = gaugeDrawEl( + await convertGauge(makeGaugeEl({ markerEnabled: val(true), markerColor: val(0x123456), markerWidth: val(200) })) + ) + expect(el.markerEnabled).toBe(true) + expect(el.markerColor).toBe(0x123456) + expect(el.markerWidth).toBe(100) + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ markerWidth: val(0) }))).markerWidth).toBe(1) + }) + + test('startAngle / endAngle pass through', async () => { + const el = gaugeDrawEl(await convertGauge(makeGaugeEl({ startAngle: val(45), endAngle: val(315) }))) + expect(el.startAngle).toBe(45) + expect(el.endAngle).toBe(315) + }) + + test('stops parsed from table rows including gradient flag', async () => { + const el = gaugeDrawEl(await convertGauge(makeGaugeEl())) + expect(el.stops).toEqual([ + { value: 0, color: 0x00ff00, gradient: false }, + { value: 66, color: 0xffff00, gradient: false }, + { value: 85, color: 0xff0000, gradient: false }, + ]) + }) + + test('stop gradient flag passes through', async () => { + const el = gaugeDrawEl( + await convertGauge( + makeGaugeEl({ + stops: val([ + { value: 0, color: 0x00ff00, gradient: true }, + { value: 100, color: 0xff0000, gradient: false }, + ]), + }) + ) + ) + expect(el.stops[0]!.gradient).toBe(true) + expect(el.stops[1]!.gradient).toBe(false) + }) + + test('stop values are NOT clamped (authored domain, mapped by renderer)', async () => { + const el = gaugeDrawEl( + await convertGauge( + makeGaugeEl({ + min: val(-100), + max: val(100), + stops: val([ + { value: -100, color: 0xff0000, gradient: false }, + { value: 100, color: 0x00ff00, gradient: false }, + ]), + }) + ) + ) + expect(el.stops[0]!.value).toBe(-100) + expect(el.stops[1]!.value).toBe(100) + }) + + test('partial stop rows fall back to defaults without throwing', async () => { + const el = gaugeDrawEl( + await convertGauge( + makeGaugeEl({ + // Rows missing some properties must not throw a TypeError + stops: val([{ value: 50 }, { color: 0x0000ff }] as any), + }) + ) + ) + expect(el.stops).toHaveLength(2) + expect(el.stops[0]).toEqual({ value: 50, color: 0, gradient: false }) + expect(el.stops[1]).toEqual({ value: 0, color: 0x0000ff, gradient: false }) + }) + + test('empty stops produce empty array', async () => { + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ stops: val([]) }))).stops).toEqual([]) + }) + + test('enabled=false with onlyEnabled=true filters element out', async () => { + const result = await convertGauge(makeGaugeEl({ enabled: val(false) }), {}, true) + expect(result.elements).toHaveLength(0) + }) + + test('enabled=false with onlyEnabled=false produces disabled draw element', async () => { + const result = await convertGauge(makeGaugeEl({ enabled: val(false) }), {}, false) + expect(gaugeDrawEl(result).enabled).toBe(false) + }) + + test('opacity scaled from percentage to 0–1', async () => { + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ opacity: val(50) }))).opacity).toBeCloseTo(0.5) + }) + + test('roundedEnds=false passes through', async () => { + expect(gaugeDrawEl(await convertGauge(makeGaugeEl({ roundedEnds: val(false) }))).roundedEnds).toBe(false) + }) + + test('contentHash changes when value changes', async () => { + const hash50 = gaugeDrawEl(await convertGauge(makeGaugeEl({ value: val(50) }))).contentHash + const hash75 = gaugeDrawEl(await convertGauge(makeGaugeEl({ value: val(75) }))).contentHash + expect(hash50).not.toBe(hash75) + }) + + test('contentHash is stable for identical inputs', async () => { + const a = gaugeDrawEl(await convertGauge(makeGaugeEl())).contentHash + const b = gaugeDrawEl(await convertGauge(makeGaugeEl())).contentHash + expect(a).toBe(b) + }) + }) }) diff --git a/companion/test/Graphics/ConvertGraphicsElements/Helper.test.ts b/companion/test/Graphics/ConvertGraphicsElements/Helper.test.ts index 505712c815..9bebfc1967 100644 --- a/companion/test/Graphics/ConvertGraphicsElements/Helper.test.ts +++ b/companion/test/Graphics/ConvertGraphicsElements/Helper.test.ts @@ -521,6 +521,14 @@ describe('ElementExpressionHelper', () => { expect(helper.getBoolean('boolProp', true)).toBe(false) }) + test('undefined (missing property) falls back to defaultValue', () => { + // A boolean field added to the schema after an element was saved is absent (undefined) + // and must use its default rather than coercing to false. + const { helper } = makeHelper(makeEl({ boolProp: val(undefined as any) })) + expect(helper.getBoolean('boolProp', true)).toBe(true) + expect(helper.getBoolean('boolProp', false)).toBe(false) + }) + test('empty string is falsy', () => { const { helper } = makeHelper(makeEl({ boolProp: val('' as any) })) expect(helper.getBoolean('boolProp', true)).toBe(false) diff --git a/companion/test/Graphics/Image.test.ts b/companion/test/Graphics/Image.test.ts index 72a122f8dc..3f104a8048 100644 --- a/companion/test/Graphics/Image.test.ts +++ b/companion/test/Graphics/Image.test.ts @@ -280,6 +280,98 @@ describe('Image drawing', () => { // circle // ------------------------------------------------------------------------- + describe('arcStroke', () => { + function pixelAt(buf: Buffer, width: number, x: number, y: number) { + const idx = (y * width + x) * 4 + return { r: buf[idx]!, g: buf[idx + 1]!, b: buf[idx + 2]!, a: buf[idx + 3]! } + } + + test('full circle - arc pixels are colored, center is transparent', () => { + const img = Image.create(60, 60, 1, null) + // radius=20, center=(30,30), lineWidth=4 → arc spans r=18..22 from center + img.arcStroke(30, 30, 20, 0, Math.PI * 2, false, { color: '#ff0000', width: 4 }) + const buf = img.buffer() + + // Center pixel (well inside ring) should be transparent + expect(pixelAt(buf, 60, 30, 30).a).toBe(0) + + // Top of circle: (30, 10) = cy - r → should be red + const top = pixelAt(buf, 60, 30, 10) + expect(top.r).toBeGreaterThan(100) + expect(top.a).toBeGreaterThan(0) + + // Right of circle: (50, 30) = cx + r → should be red + const right = pixelAt(buf, 60, 50, 30) + expect(right.r).toBeGreaterThan(100) + expect(right.a).toBeGreaterThan(0) + }) + + test('lineWidth=0 - draws nothing', () => { + const img = Image.create(60, 60, 1, null) + img.arcStroke(30, 30, 20, 0, Math.PI * 2, false, { color: '#ff0000', width: 0 }) + const buf = img.buffer() + for (let i = 3; i < buf.length; i += 4) { + expect(buf[i]).toBe(0) + } + }) + + test('radius=0 - draws nothing', () => { + const img = Image.create(60, 60, 1, null) + img.arcStroke(30, 30, 0, 0, Math.PI * 2, false, { color: '#ff0000', width: 4 }) + const buf = img.buffer() + for (let i = 3; i < buf.length; i += 4) { + expect(buf[i]).toBe(0) + } + }) + + test('clockwise semicircle - colors lower half, not upper', () => { + const img = Image.create(60, 60, 1, null) + // CW from 0 (3 o'clock) to π (9 o'clock) = lower half (right→bottom→left) + img.arcStroke(30, 30, 20, 0, Math.PI, false, { color: '#00ff00', width: 4 }) + const buf = img.buffer() + + // Bottom of circle (cy+r = 50) should have color + expect(pixelAt(buf, 60, 30, 50).a).toBeGreaterThan(0) + + // Top of circle (cy-r = 10) should be transparent + expect(pixelAt(buf, 60, 30, 10).a).toBe(0) + }) + + test('anticlockwise semicircle - colors upper half, not lower', () => { + const img = Image.create(60, 60, 1, null) + // CCW from 0 to π = upper half (right→top→left) + img.arcStroke(30, 30, 20, 0, Math.PI, true, { color: '#0000ff', width: 4 }) + const buf = img.buffer() + + // Top of circle (cy-r = 10) should have color + expect(pixelAt(buf, 60, 30, 10).a).toBeGreaterThan(0) + + // Bottom of circle (cy+r = 50) should be transparent + expect(pixelAt(buf, 60, 30, 50).a).toBe(0) + }) + + test('snapshot - full circle and quarter arcs', async () => { + const img = Image.create(144, 72, 1, null) + img.fillColor('#111111') + // Full circle on left + img.arcStroke(36, 36, 28, 0, Math.PI * 2, false, { color: '#ff0000', width: 5 }) + // CW bottom-half in middle (green) + img.arcStroke(72, 36, 28, 0, Math.PI, false, { color: '#00ff00', width: 5 }) + // CCW top-half on right (blue) + img.arcStroke(108, 36, 28, 0, Math.PI, true, { color: '#0088ff', width: 5 }) + await expect(img.canvasImage).toMatchImageSnapshot() + }) + + test('snapshot - thick vs thin arc', async () => { + const img = Image.create(144, 72, 1, null) + img.fillColor('#111111') + // 270° arc (0 → 3π/2) – avoids angles that normalise to the same point + img.arcStroke(36, 36, 28, 0, Math.PI * 1.5, false, { color: '#ff8800', width: 2 }) + img.arcStroke(108, 36, 28, 0, Math.PI * 1.5, false, { color: '#ff8800', width: 14 }) + await expect(img.canvasImage).toMatchImageSnapshot() + }) + }) + describe('circle', () => { test('filled full circle', async () => { const img = Image.create(72, 58, 1, null) diff --git a/companion/test/Graphics/LayeredRenderer.test.ts b/companion/test/Graphics/LayeredRenderer.test.ts index a15f8c6448..8d504bcae3 100644 --- a/companion/test/Graphics/LayeredRenderer.test.ts +++ b/companion/test/Graphics/LayeredRenderer.test.ts @@ -8,6 +8,7 @@ import type { ButtonGraphicsBoxDrawElement, ButtonGraphicsCanvasDrawElement, ButtonGraphicsCircleDrawElement, + ButtonGraphicsGaugeDrawElement, ButtonGraphicsGroupDrawElement, ButtonGraphicsImageDrawElement, ButtonGraphicsLineDrawElement, @@ -1061,4 +1062,339 @@ describe('GraphicsLayeredButtonRenderer', () => { await expect(img.canvasImage).toMatchImageSnapshot() }) }) + + describe('gauge element', () => { + const DEFAULT_STOPS: ButtonGraphicsGaugeDrawElement['stops'] = [ + { value: 0, color: 0x00ff00, gradient: false }, + { value: 66, color: 0xffff00, gradient: false }, + { value: 85, color: 0xff0000, gradient: false }, + ] + + function makeGaugeElement(overrides: Partial = {}): ButtonGraphicsGaugeDrawElement { + return { + ...ELEMENT_BASE, + id: 'gauge-1', + type: 'gauge', + x: 0, + y: 0, + width: 1, + height: 1, + rotation: 0, + value: 50, + min: 0, + max: 100, + origin: 0, + symmetric: false, + orientation: 'horizontal', + reverse: false, + trackWidth: 100, + startAngle: 0, + endAngle: 360, + ringWidth: 20, + roundedEnds: true, + fillEnabled: true, + multiColour: true, + stops: DEFAULT_STOPS, + markerEnabled: false, + markerColor: 0xffffff, + markerWidth: 15, + trackStyle: 'transparent', + trackAmount: 70, + ...overrides, + } + } + + const drawOpts = { show_topbar: false, show_status_icons: false } as const + + async function drawGauge(gauge: ButtonGraphicsGaugeDrawElement, size = { w: 72, h: 58 }): Promise { + const img = Image.create(size.w, size.h, 1, null) + await GraphicsLayeredButtonRenderer.draw( + img, + makeStyle({ ...drawOpts, elements: [gauge] }), + new Set(), + null, + DEFAULT_PADDING + ) + return img.canvasImage + } + + test('value=0 - only inactive background visible', async () => { + await expect(await drawGauge(makeGaugeElement({ value: 0 }))).toMatchImageSnapshot() + }) + + test('value=50 - first segment partially active', async () => { + await expect(await drawGauge(makeGaugeElement({ value: 50 }))).toMatchImageSnapshot() + }) + + test('value=75 - two segments active (green + yellow), red inactive', async () => { + await expect(await drawGauge(makeGaugeElement({ value: 75 }))).toMatchImageSnapshot() + }) + + test('value=100 - all segments active, no inactive', async () => { + await expect(await drawGauge(makeGaugeElement({ value: 100 }))).toMatchImageSnapshot() + }) + + test('reverse=true - fills from right', async () => { + await expect(await drawGauge(makeGaugeElement({ value: 50, reverse: true }))).toMatchImageSnapshot() + }) + + test('multiColour=false - single colour for entire active region', async () => { + await expect(await drawGauge(makeGaugeElement({ value: 75, multiColour: false }))).toMatchImageSnapshot() + }) + + test('trackStyle=dimmed - inactive portions darkened', async () => { + await expect( + await drawGauge(makeGaugeElement({ value: 50, trackStyle: 'dimmed', trackAmount: 70 })) + ).toMatchImageSnapshot() + }) + + test('trackAmount=0 - inactive portions invisible', async () => { + await expect(await drawGauge(makeGaugeElement({ value: 50, trackAmount: 0 }))).toMatchImageSnapshot() + }) + + test('trackAmount=100 - inactive same as active colour', async () => { + await expect(await drawGauge(makeGaugeElement({ value: 50, trackAmount: 100 }))).toMatchImageSnapshot() + }) + + test('orientation=vertical reverse=false - fills from bottom', async () => { + await expect(await drawGauge(makeGaugeElement({ value: 50, orientation: 'vertical' }))).toMatchImageSnapshot() + }) + + test('orientation=vertical reverse=true - fills from top', async () => { + await expect( + await drawGauge(makeGaugeElement({ value: 50, orientation: 'vertical', reverse: true })) + ).toMatchImageSnapshot() + }) + + test('empty stops - nothing drawn', async () => { + await expect(await drawGauge(makeGaugeElement({ stops: [] }))).toMatchImageSnapshot() + }) + + test('single segment - full bar one colour', async () => { + await expect( + await drawGauge(makeGaugeElement({ value: 50, stops: [{ value: 0, color: 0x0088ff }] })) + ).toMatchImageSnapshot() + }) + + // Helper: draw a gauge on top of a dark box so inactive transparent arcs are visible + async function drawRing( + overrides: Partial, + size = { w: 72, h: 72 } + ): Promise { + const img = Image.create(size.w, size.h, 1, null) + const bg = makeBoxElement({ color: 0x222222 }) + const gauge = makeGaugeElement({ orientation: 'ring', ...overrides }) + await GraphicsLayeredButtonRenderer.draw( + img, + makeStyle({ ...drawOpts, elements: [bg, gauge] }), + new Set(), + null, + DEFAULT_PADDING + ) + return img.canvasImage + } + + test('ring value=33 - one colour, within first segment', async () => { + await expect(await drawRing({ value: 33 })).toMatchImageSnapshot() + }) + + test('ring value=50 - midway through first segment', async () => { + await expect(await drawRing({ value: 50 })).toMatchImageSnapshot() + }) + + test('ring value=66 - exactly at first segment boundary', async () => { + await expect(await drawRing({ value: 66 })).toMatchImageSnapshot() + }) + + test('ring value=75 - crossing into yellow segment', async () => { + await expect(await drawRing({ value: 75 })).toMatchImageSnapshot() + }) + + test('ring value=90 - crossing into red segment', async () => { + await expect(await drawRing({ value: 90 })).toMatchImageSnapshot() + }) + + test('ring value=0 - inactive arc only (dark bg makes it visible)', async () => { + await expect(await drawRing({ value: 0 })).toMatchImageSnapshot() + }) + + test('ring value=100 - fully active', async () => { + await expect(await drawRing({ value: 100 })).toMatchImageSnapshot() + }) + + test('ring value=75 dimmed inactive - both halves clearly visible', async () => { + await expect(await drawRing({ value: 75, trackStyle: 'dimmed', trackAmount: 40 })).toMatchImageSnapshot() + }) + + test('ring reverse=true value=75 - counter-clockwise', async () => { + await expect(await drawRing({ value: 75, reverse: true })).toMatchImageSnapshot() + }) + + test('ring thin ringWidth=8', async () => { + await expect(await drawRing({ value: 75, ringWidth: 8 })).toMatchImageSnapshot() + }) + + test('ring thick ringWidth=40', async () => { + await expect(await drawRing({ value: 75, ringWidth: 40 })).toMatchImageSnapshot() + }) + + test('ring multiColour=false value=75 - single colour active', async () => { + await expect(await drawRing({ value: 75, multiColour: false })).toMatchImageSnapshot() + }) + + test('ring roundedEnds=false value=75 - flat ends', async () => { + await expect(await drawRing({ value: 75, roundedEnds: false })).toMatchImageSnapshot() + }) + + test('ring in non-square element - stays circular', async () => { + await expect(await drawRing({ value: 50 }, { w: 72, h: 58 })).toMatchImageSnapshot() + }) + + test('unsorted stops - sorted before rendering', async () => { + await expect( + await drawGauge( + makeGaugeElement({ + value: 75, + stops: [ + { value: 85, color: 0xff0000 }, + { value: 0, color: 0x00ff00 }, + { value: 66, color: 0xffff00 }, + ], + }) + ) + ).toMatchImageSnapshot() + }) + + // --- Value mapping: min / max / origin / symmetric --- + + test('min/max maps an arbitrary range onto the gauge', async () => { + // Audio-style range: value 0 sits ~91% of the way along -232..24 + await expect(await drawGauge(makeGaugeElement({ value: 0, min: -232, max: 24 }))).toMatchImageSnapshot() + }) + + test('origin at midpoint - pan fills right of centre', async () => { + await expect( + await drawGauge(makeGaugeElement({ value: 75, origin: 50, stops: [{ value: 0, color: 0x00aaff }] })) + ).toMatchImageSnapshot() + }) + + test('origin at midpoint - pan fills left of centre', async () => { + await expect( + await drawGauge(makeGaugeElement({ value: 25, origin: 50, stops: [{ value: 0, color: 0x00aaff }] })) + ).toMatchImageSnapshot() + }) + + test('symmetric - fill grows both ways from origin (stereo width)', async () => { + await expect( + await drawGauge( + makeGaugeElement({ value: 60, origin: 50, symmetric: true, stops: [{ value: 0, color: 0x00ff88 }] }) + ) + ).toMatchImageSnapshot() + }) + + // --- Track width: fill is wider than the (narrowed) track --- + + test('trackWidth=40 - fill wider than track', async () => { + await expect(await drawGauge(makeGaugeElement({ value: 60, trackWidth: 40 }))).toMatchImageSnapshot() + }) + + test('vertical trackWidth=50 - narrow track, full-width fill', async () => { + await expect( + await drawGauge(makeGaugeElement({ value: 60, orientation: 'vertical', trackWidth: 50 })) + ).toMatchImageSnapshot() + }) + + test('ring trackWidth=50 - track narrower than fill within ring width', async () => { + await expect(await drawRing({ value: 75, trackWidth: 50 })).toMatchImageSnapshot() + }) + + // --- Fill toggle --- + + test('fillEnabled=false - only the track renders', async () => { + await expect(await drawGauge(makeGaugeElement({ value: 75, fillEnabled: false }))).toMatchImageSnapshot() + }) + + // --- Gradient stops --- + + test('gradient stop - blends toward the next stop colour', async () => { + await expect( + await drawGauge( + makeGaugeElement({ + value: 100, + stops: [ + { value: 0, color: 0x00ff00, gradient: true }, + { value: 100, color: 0xff0000, gradient: false }, + ], + }) + ) + ).toMatchImageSnapshot() + }) + + test('first stop not at zero - anchored so no gap forms', async () => { + await expect( + await drawGauge( + makeGaugeElement({ + value: 100, + stops: [ + { value: 40, color: 0x00ff00 }, + { value: 80, color: 0xff0000 }, + ], + }) + ) + ).toMatchImageSnapshot() + }) + + // --- Marker --- + + test('marker - line at value, rounded caps with roundedEnds', async () => { + await expect( + await drawGauge(makeGaugeElement({ value: 50, markerEnabled: true, markerColor: 0xffffff })) + ).toMatchImageSnapshot() + }) + + test('marker - flat caps when roundedEnds=false', async () => { + await expect( + await drawGauge(makeGaugeElement({ value: 50, markerEnabled: true, markerColor: 0xffffff, roundedEnds: false })) + ).toMatchImageSnapshot() + }) + + test('marker - mirrors in symmetric mode', async () => { + await expect( + await drawGauge( + makeGaugeElement({ + value: 60, + origin: 50, + symmetric: true, + markerEnabled: true, + markerColor: 0xffffff, + stops: [{ value: 0, color: 0x00ff88 }], + }) + ) + ).toMatchImageSnapshot() + }) + + test('ring marker - arc bead following the curve', async () => { + await expect( + await drawRing({ value: 50, markerEnabled: true, markerColor: 0xffffff, markerWidth: 25 }) + ).toMatchImageSnapshot() + }) + + // --- Circular start/end angle (gap positioning) --- + + test('ring partial arc - gap at the bottom (270° arc)', async () => { + await expect(await drawRing({ value: 75, startAngle: 225, endAngle: 135 })).toMatchImageSnapshot() + }) + + test('ring partial arc - rounded track ends follow roundedEnds', async () => { + await expect( + await drawRing({ value: 40, startAngle: 225, endAngle: 135, roundedEnds: true }) + ).toMatchImageSnapshot() + }) + + test('ring partial arc - flat track ends when roundedEnds=false', async () => { + await expect( + await drawRing({ value: 40, startAngle: 225, endAngle: 135, roundedEnds: false }) + ).toMatchImageSnapshot() + }) + }) }) diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_box_properties_box_with_opacity_-_semi-transparent_over_another_element.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_box_properties_box_with_opacity_-_semi-transparent_over_another_element.png index 5ef6f16d4c..da4f336d18 100644 Binary files a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_box_properties_box_with_opacity_-_semi-transparent_over_another_element.png and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_box_properties_box_with_opacity_-_semi-transparent_over_another_element.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_empty_stops_-_nothing_drawn.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_empty_stops_-_nothing_drawn.png new file mode 100644 index 0000000000..f74b953579 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_empty_stops_-_nothing_drawn.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_fillEnabled_false_-_only_the_track_renders.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_fillEnabled_false_-_only_the_track_renders.png new file mode 100644 index 0000000000..44402687fa Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_fillEnabled_false_-_only_the_track_renders.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_first_stop_not_at_zero_-_anchored_so_no_gap_forms.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_first_stop_not_at_zero_-_anchored_so_no_gap_forms.png new file mode 100644 index 0000000000..e51561b290 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_first_stop_not_at_zero_-_anchored_so_no_gap_forms.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_gradient_stop_-_blends_toward_the_next_stop_colour.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_gradient_stop_-_blends_toward_the_next_stop_colour.png new file mode 100644 index 0000000000..2227a3a2dd Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_gradient_stop_-_blends_toward_the_next_stop_colour.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_marker_-_flat_caps_when_roundedEnds_false.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_marker_-_flat_caps_when_roundedEnds_false.png new file mode 100644 index 0000000000..4c491d702d Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_marker_-_flat_caps_when_roundedEnds_false.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_marker_-_line_at_value_rounded_caps_with_roundedEnds.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_marker_-_line_at_value_rounded_caps_with_roundedEnds.png new file mode 100644 index 0000000000..4c491d702d Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_marker_-_line_at_value_rounded_caps_with_roundedEnds.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_marker_-_mirrors_in_symmetric_mode.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_marker_-_mirrors_in_symmetric_mode.png new file mode 100644 index 0000000000..bdffbbbb49 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_marker_-_mirrors_in_symmetric_mode.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_min_max_maps_an_arbitrary_range_onto_the_gauge.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_min_max_maps_an_arbitrary_range_onto_the_gauge.png new file mode 100644 index 0000000000..170f45ea2d Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_min_max_maps_an_arbitrary_range_onto_the_gauge.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_multiColour_false_-_single_colour_for_entire_active_region.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_multiColour_false_-_single_colour_for_entire_active_region.png new file mode 100644 index 0000000000..26bc5f1a01 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_multiColour_false_-_single_colour_for_entire_active_region.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_orientation_vertical_reverse_false_-_fills_from_bottom.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_orientation_vertical_reverse_false_-_fills_from_bottom.png new file mode 100644 index 0000000000..544e1801f7 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_orientation_vertical_reverse_false_-_fills_from_bottom.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_orientation_vertical_reverse_true_-_fills_from_top.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_orientation_vertical_reverse_true_-_fills_from_top.png new file mode 100644 index 0000000000..67d6c49c36 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_orientation_vertical_reverse_true_-_fills_from_top.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_origin_at_midpoint_-_pan_fills_left_of_centre.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_origin_at_midpoint_-_pan_fills_left_of_centre.png new file mode 100644 index 0000000000..adecb4bd96 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_origin_at_midpoint_-_pan_fills_left_of_centre.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_origin_at_midpoint_-_pan_fills_right_of_centre.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_origin_at_midpoint_-_pan_fills_right_of_centre.png new file mode 100644 index 0000000000..ae2e3f1602 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_origin_at_midpoint_-_pan_fills_right_of_centre.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_reverse_true_-_fills_from_right.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_reverse_true_-_fills_from_right.png new file mode 100644 index 0000000000..1bfaa483dc Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_reverse_true_-_fills_from_right.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_in_non-square_element_-_stays_circular.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_in_non-square_element_-_stays_circular.png new file mode 100644 index 0000000000..b209336368 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_in_non-square_element_-_stays_circular.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_marker_-_arc_bead_following_the_curve.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_marker_-_arc_bead_following_the_curve.png new file mode 100644 index 0000000000..b2ba4bef26 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_marker_-_arc_bead_following_the_curve.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_multiColour_false_value_75_-_single_colour_active.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_multiColour_false_value_75_-_single_colour_active.png new file mode 100644 index 0000000000..5f25420fe9 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_multiColour_false_value_75_-_single_colour_active.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_partial_arc_-_flat_track_ends_when_roundedEnds_false.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_partial_arc_-_flat_track_ends_when_roundedEnds_false.png new file mode 100644 index 0000000000..2e70041830 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_partial_arc_-_flat_track_ends_when_roundedEnds_false.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_partial_arc_-_gap_at_the_bottom_270_arc.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_partial_arc_-_gap_at_the_bottom_270_arc.png new file mode 100644 index 0000000000..f7251a4f6c Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_partial_arc_-_gap_at_the_bottom_270_arc.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_partial_arc_-_rounded_track_ends_follow_roundedEnds.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_partial_arc_-_rounded_track_ends_follow_roundedEnds.png new file mode 100644 index 0000000000..d24f63073a Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_partial_arc_-_rounded_track_ends_follow_roundedEnds.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_reverse_true_value_75_-_counter-clockwise.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_reverse_true_value_75_-_counter-clockwise.png new file mode 100644 index 0000000000..ba2b9d3ca2 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_reverse_true_value_75_-_counter-clockwise.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_roundedEnds_false_value_75_-_flat_ends.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_roundedEnds_false_value_75_-_flat_ends.png new file mode 100644 index 0000000000..ea15b2c551 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_roundedEnds_false_value_75_-_flat_ends.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_thick_ringWidth_40.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_thick_ringWidth_40.png new file mode 100644 index 0000000000..bb01a9023a Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_thick_ringWidth_40.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_thin_ringWidth_8.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_thin_ringWidth_8.png new file mode 100644 index 0000000000..d6d84cb1e9 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_thin_ringWidth_8.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_trackWidth_50_-_track_narrower_than_fill_within_ring_width.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_trackWidth_50_-_track_narrower_than_fill_within_ring_width.png new file mode 100644 index 0000000000..5dc185e1bc Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_trackWidth_50_-_track_narrower_than_fill_within_ring_width.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_0_-_inactive_arc_only_dark_bg_makes_it_visible.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_0_-_inactive_arc_only_dark_bg_makes_it_visible.png new file mode 100644 index 0000000000..49eaab8d11 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_0_-_inactive_arc_only_dark_bg_makes_it_visible.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_100_-_fully_active.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_100_-_fully_active.png new file mode 100644 index 0000000000..dce3f02a77 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_100_-_fully_active.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_33_-_one_colour_within_first_segment.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_33_-_one_colour_within_first_segment.png new file mode 100644 index 0000000000..bb97b2a4a7 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_33_-_one_colour_within_first_segment.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_50_-_midway_through_first_segment.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_50_-_midway_through_first_segment.png new file mode 100644 index 0000000000..867e89bf39 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_50_-_midway_through_first_segment.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_66_-_exactly_at_first_segment_boundary.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_66_-_exactly_at_first_segment_boundary.png new file mode 100644 index 0000000000..7e163b8e89 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_66_-_exactly_at_first_segment_boundary.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_75_-_crossing_into_yellow_segment.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_75_-_crossing_into_yellow_segment.png new file mode 100644 index 0000000000..0176da26d9 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_75_-_crossing_into_yellow_segment.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_75_dimmed_inactive_-_both_halves_clearly_visible.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_75_dimmed_inactive_-_both_halves_clearly_visible.png new file mode 100644 index 0000000000..9c7426d073 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_75_dimmed_inactive_-_both_halves_clearly_visible.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_90_-_crossing_into_red_segment.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_90_-_crossing_into_red_segment.png new file mode 100644 index 0000000000..6792650f19 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_90_-_crossing_into_red_segment.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_single_segment_-_full_bar_one_colour.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_single_segment_-_full_bar_one_colour.png new file mode 100644 index 0000000000..81759cf8c1 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_single_segment_-_full_bar_one_colour.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_symmetric_-_fill_grows_both_ways_from_origin_stereo_width.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_symmetric_-_fill_grows_both_ways_from_origin_stereo_width.png new file mode 100644 index 0000000000..00d7ba1393 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_symmetric_-_fill_grows_both_ways_from_origin_stereo_width.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackAmount_0_-_inactive_portions_invisible.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackAmount_0_-_inactive_portions_invisible.png new file mode 100644 index 0000000000..9f71ce2a67 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackAmount_0_-_inactive_portions_invisible.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackAmount_100_-_inactive_same_as_active_colour.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackAmount_100_-_inactive_same_as_active_colour.png new file mode 100644 index 0000000000..03a2cac63e Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackAmount_100_-_inactive_same_as_active_colour.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackStyle_dimmed_-_inactive_portions_darkened.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackStyle_dimmed_-_inactive_portions_darkened.png new file mode 100644 index 0000000000..bd3b803d3d Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackStyle_dimmed_-_inactive_portions_darkened.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackWidth_40_-_fill_wider_than_track.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackWidth_40_-_fill_wider_than_track.png new file mode 100644 index 0000000000..4af17f0d6e Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_trackWidth_40_-_fill_wider_than_track.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_unsorted_stops_-_sorted_before_rendering.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_unsorted_stops_-_sorted_before_rendering.png new file mode 100644 index 0000000000..c1c92652eb Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_unsorted_stops_-_sorted_before_rendering.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_0_-_only_inactive_background_visible.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_0_-_only_inactive_background_visible.png new file mode 100644 index 0000000000..44402687fa Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_0_-_only_inactive_background_visible.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_100_-_all_segments_active_no_inactive.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_100_-_all_segments_active_no_inactive.png new file mode 100644 index 0000000000..03a2cac63e Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_100_-_all_segments_active_no_inactive.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_50_-_first_segment_partially_active.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_50_-_first_segment_partially_active.png new file mode 100644 index 0000000000..e569f8f05b Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_50_-_first_segment_partially_active.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_75_-_two_segments_active_green_yellow_red_inactive.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_75_-_two_segments_active_green_yellow_red_inactive.png new file mode 100644 index 0000000000..c1c92652eb Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_75_-_two_segments_active_green_yellow_red_inactive.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_vertical_trackWidth_50_-_narrow_track_full-width_fill.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_vertical_trackWidth_50_-_narrow_track_full-width_fill.png new file mode 100644 index 0000000000..d96103a432 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_vertical_trackWidth_50_-_narrow_track_full-width_fill.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_group_properties_group_with_rotation.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_group_properties_group_with_rotation.png index 858443ea5a..366d17216b 100644 Binary files a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_group_properties_group_with_rotation.png and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_group_properties_group_with_rotation.png differ diff --git a/companion/test/Graphics/__snapshots__/Image_drawing_arcStroke_snapshot_-_full_circle_and_quarter_arcs.png b/companion/test/Graphics/__snapshots__/Image_drawing_arcStroke_snapshot_-_full_circle_and_quarter_arcs.png new file mode 100644 index 0000000000..dc6351883d Binary files /dev/null and b/companion/test/Graphics/__snapshots__/Image_drawing_arcStroke_snapshot_-_full_circle_and_quarter_arcs.png differ diff --git a/companion/test/Graphics/__snapshots__/Image_drawing_arcStroke_snapshot_-_thick_vs_thin_arc.png b/companion/test/Graphics/__snapshots__/Image_drawing_arcStroke_snapshot_-_thick_vs_thin_arc.png new file mode 100644 index 0000000000..701f2e8afc Binary files /dev/null and b/companion/test/Graphics/__snapshots__/Image_drawing_arcStroke_snapshot_-_thick_vs_thin_arc.png differ diff --git a/shared-lib/lib/Graphics/ElementPropertiesSchemas.ts b/shared-lib/lib/Graphics/ElementPropertiesSchemas.ts index 8e3787c6d2..1bf5a82599 100644 --- a/shared-lib/lib/Graphics/ElementPropertiesSchemas.ts +++ b/shared-lib/lib/Graphics/ElementPropertiesSchemas.ts @@ -1,6 +1,7 @@ import { CompanionFieldVariablesSupport, type SomeCompanionInputField } from '../Model/Options.js' import type { ButtonGraphicsBoxElement, + ButtonGraphicsGaugeElement, ButtonGraphicsImageElement, ButtonGraphicsTextElement, } from '../Model/StyleLayersModel.js' @@ -479,6 +480,266 @@ export const referenceElementSchema: ElementSchemaSection[] = [ }, ] +export const gaugeElementSchema: ElementSchemaSection[] = [ + { id: 'layer', label: 'Layer', fields: [...commonElementFields] }, + { id: 'position', label: 'Position & Size', fields: [...boundsFields, ...rotationFields] }, + { + id: 'value', + label: 'Value', + fields: [ + { + type: 'number', + id: 'value', + label: 'Value', + tooltip: 'The current value of the gauge, in the Min..Max range defined below.', + default: 0, + min: -1000000, + max: 1000000, + step: 1, + }, + { + type: 'number', + id: 'min', + label: 'Minimum', + tooltip: 'The value mapped to the start of the gauge.', + default: 0, + min: -1000000, + max: 1000000, + step: 1, + }, + { + type: 'number', + id: 'max', + label: 'Maximum', + tooltip: 'The value mapped to the end of the gauge.', + default: 100, + min: -1000000, + max: 1000000, + step: 1, + }, + { + type: 'number', + id: 'origin', + label: 'Origin (0-point)', + tooltip: + 'The value the fill grows from. Set to the Minimum for a normal bar, or to the midpoint for a bipolar (pan/centre) gauge.', + default: 0, + min: -1000000, + max: 1000000, + step: 1, + }, + { + type: 'checkbox', + id: 'symmetric', + label: 'Symmetric (mirror around origin)', + tooltip: + 'When enabled the fill grows outward in both directions from the origin as the value rises (e.g. stereo width). When disabled the fill grows from the origin toward the value (e.g. pan).', + default: false, + }, + ], + }, + { + id: 'appearance', + label: 'Appearance', + fields: [ + { + type: 'dropdown', + id: 'orientation', + label: 'Orientation', + choices: [ + { id: 'horizontal', label: 'Horizontal' }, + { id: 'vertical', label: 'Vertical' }, + { id: 'ring', label: 'Ring' }, + ], + default: 'horizontal', + }, + { + type: 'checkbox', + id: 'reverse', + label: 'Reverse direction', + tooltip: + 'When enabled, the gauge fills from the opposite end (right-to-left for horizontal, top-to-bottom for vertical, counter-clockwise for ring).', + default: false, + }, + ], + }, + { + id: 'circular', + label: 'Circular styling', + fields: [ + { + type: 'number', + id: 'startAngle', + label: 'Start angle', + tooltip: 'Angle of the start of the arc, in degrees clockwise from the top. Only applies to ring orientation.', + default: 0, + min: 0, + max: 360, + step: 1, + }, + { + type: 'number', + id: 'endAngle', + label: 'End angle', + tooltip: + 'Angle of the end of the arc, in degrees clockwise from the top. Any space between end and start becomes the gap. Only applies to ring orientation.', + default: 360, + min: 0, + max: 360, + step: 1, + }, + { + type: 'number', + id: 'ringWidth', + label: 'Ring width (%)', + tooltip: 'Width of the ring as a percentage of the shorter dimension. Only applies to ring orientation.', + default: 20, + min: 1, + max: 50, + step: 1, + }, + { + type: 'checkbox', + id: 'roundedEnds', + label: 'Rounded ends', + tooltip: 'Round the ends of the active arc. Only applies to ring orientation.', + default: true, + }, + ], + }, + { + id: 'fill', + label: 'Fill', + fields: [ + { + type: 'checkbox', + id: 'fillEnabled', + label: 'Show fill', + tooltip: 'Draw the filled portion of the gauge.', + default: true, + }, + { + type: 'checkbox', + id: 'multiColour', + label: 'Multi-colour fill', + tooltip: + 'When enabled, each colour stop is visible in the filled portion. When disabled, only the active stop colour is used for the entire filled area.', + default: true, + }, + { + type: 'internal:list', + id: 'stops', + label: 'Colour stops', + tooltip: + 'Define colour stops for the gauge fill. Each stop specifies the value at which that colour starts. Enable "Gradient" to blend toward the next stop.', + addLabel: 'Add stop', + minItems: 1, + fields: [ + { + id: 'value', + type: 'number', + label: 'Value', + min: -1000000, + max: 1000000, + step: 1, + default: 0, + }, + { + id: 'color', + type: 'colorpicker', + label: 'Colour', + default: 0x00ff00, + enableAlpha: false, + returnType: 'number', + }, + { + id: 'gradient', + type: 'checkbox', + label: 'Gradient to next', + default: false, + }, + ], + default: [ + { value: 0, color: 0x00ff00, gradient: false }, + { value: 66, color: 0xffff00, gradient: false }, + { value: 85, color: 0xff0000, gradient: false }, + ], + }, + ], + }, + { + id: 'marker', + label: 'Marker', + fields: [ + { + type: 'checkbox', + id: 'markerEnabled', + label: 'Show marker', + tooltip: 'Draw a marker line at the current value, across the full width of the fill.', + default: false, + }, + { + type: 'colorpicker', + id: 'markerColor', + label: 'Colour', + default: 0xffffff, + enableAlpha: true, + returnType: 'number', + }, + { + type: 'number', + id: 'markerWidth', + label: 'Width (%)', + tooltip: 'Thickness of the marker line as a percentage of the fill width.', + default: 15, + min: 1, + max: 100, + step: 1, + }, + ], + }, + { + id: 'track', + label: 'Track (background)', + fields: [ + { + type: 'dropdown', + id: 'trackStyle', + label: 'Style', + tooltip: 'How to render the unfilled track behind the fill.', + choices: [ + { id: 'transparent', label: 'Transparent' }, + { id: 'dimmed', label: 'Dimmed (darker)' }, + ], + default: 'transparent', + }, + { + type: 'number', + id: 'trackAmount', + label: 'Amount (%)', + tooltip: + 'How much of the original colour remains in the unfilled track. 0 = invisible / black, 100 = same as the active colour.', + default: 70, + min: 0, + max: 100, + step: 1, + range: true, + }, + { + type: 'number', + id: 'trackWidth', + label: 'Track width (%)', + tooltip: 'Width of the track relative to the available space, centred.', + default: 100, + min: 0, + max: 100, + step: 1, + range: true, + }, + ], + }, +] + /** * Section-structured schemas per element type. */ @@ -492,6 +753,7 @@ export const elementSchemas = { composite: compositeElementSchema, canvas: canvasElementSchema, reference: referenceElementSchema, + gauge: gaugeElementSchema, } as const satisfies Record export function getElementSchemaProperty( @@ -536,4 +798,8 @@ export const elementSimpleModeFields = { // 'color', ] satisfies ReadonlyArray, + gauge: [ + // + 'value', + ] satisfies ReadonlyArray, } as const diff --git a/shared-lib/lib/Graphics/ImageBase.ts b/shared-lib/lib/Graphics/ImageBase.ts index 7d0b1259c1..eea5601e0f 100644 --- a/shared-lib/lib/Graphics/ImageBase.ts +++ b/shared-lib/lib/Graphics/ImageBase.ts @@ -46,6 +46,10 @@ export interface LineStyle { * Line width in pixels */ width: number + /** + * Line cap style (defaults to 'butt') + */ + cap?: CanvasLineCap } /** Take a limited view of CompanionImageContext2D, based on what skia canvas supports */ @@ -151,7 +155,7 @@ export abstract class ImageBase Promise): Promise { const oldAlpha = this.context2d.globalAlpha - this.context2d.globalAlpha = alpha + this.context2d.globalAlpha *= alpha try { await fcn() @@ -227,13 +231,19 @@ export abstract class ImageBase { - await img.usingTemporaryLayer(element.opacity, async (img) => { + await img.usingTemporaryLayer(element.opacity, async (img) => { + await img.usingRotation(drawBounds, element.rotation, async () => { const { x, y, width, height, maxX, maxY } = drawBounds // Orange background @@ -315,7 +319,7 @@ export class GraphicsLayeredButtonRenderer { const marginX = 2 * marginScale * drawBounds.width const marginY = 1 * marginScale * drawBounds.height - await img.usingAlpha(element.opacity, async () => { + await img.usingTemporaryLayer(element.opacity, async (img) => { await img.usingRotation(drawBounds, element.rotation, async () => { img.drawAlignedText( drawBounds.x + marginX, @@ -354,7 +358,7 @@ export class GraphicsLayeredButtonRenderer { // Calculate a pixel width, relative to the parent bounds const borderWidth = Math.max(0, parentBounds.width, parentBounds.height) * element.borderWidth - await img.usingAlpha(element.opacity, async () => { + await img.usingTemporaryLayer(element.opacity, async (img) => { await img.usingRotation(drawBounds, element.rotation, async () => { img.box( drawBounds.x, @@ -420,7 +424,7 @@ export class GraphicsLayeredButtonRenderer { // Calculate a pixel width, relative to the parent bounds const borderWidth = Math.max(0, parentBounds.width, parentBounds.height) * element.borderWidth - await img.usingAlpha(element.opacity, async () => { + await img.usingTemporaryLayer(element.opacity, async (img) => { const midX = drawBounds.x + drawBounds.width / 2 const midY = drawBounds.y + drawBounds.height / 2 const radiusX = drawBounds.width / 2 @@ -450,6 +454,319 @@ export class GraphicsLayeredButtonRenderer { return drawBounds } + static async #drawGaugeElement( + img: ImageBase, + parentBounds: DrawBounds, + element: ButtonGraphicsGaugeDrawElement, + skipDraw: boolean + ): Promise { + const drawBounds = parentBounds.compose(element.x, element.y, element.width, element.height) + if (skipDraw) return drawBounds + + const { x, y, width, height, maxX, maxY } = drawBounds + const { orientation, reverse, multiColour, trackStyle, symmetric } = element + + const finite = (v: unknown, fallback: number): number => { + const n = Number(v) + return Number.isFinite(n) ? n : fallback + } + + // --- Value mapping (authored Min..Max domain → 0–100 track position) --- + const min = finite(element.min, 0) + const max = finite(element.max, 100) + const range = max - min + const norm = (v: number): number => { + if (range === 0) return 0 + return Math.max(0, Math.min(100, ((v - min) / range) * 100)) + } + + const valuePos = norm(finite(element.value, 0)) + const originPos = norm(finite(element.origin, min)) + + // Active fill interval in track-position space (0–100). + // Non-symmetric: from the origin toward the value (handles normal bars, centre-bar, pan). + // Symmetric: a band of length = value, centred on the origin (handles stereo-width). + let fillLo: number + let fillHi: number + if (symmetric) { + const half = valuePos / 2 + fillLo = Math.max(0, Math.min(100, originPos - half)) + fillHi = Math.max(0, Math.min(100, originPos + half)) + } else { + fillLo = Math.min(originPos, valuePos) + fillHi = Math.max(originPos, valuePos) + } + const hasFill = element.fillEnabled && fillHi > fillLo + + const trackWidth = Math.max(0, Math.min(100, finite(element.trackWidth, 100))) / 100 + const trackAmount = Math.max(0, Math.min(100, finite(element.trackAmount, 0))) / 100 + + // --- Colour stops → runs between consecutive stops, with the first anchored to position 0 + // and the last extended to 100 so the track never has an uncoloured gap. --- + const stops = [...element.stops] + .map((s) => ({ pos: norm(finite(s.value, 0)), color: finite(s.color, 0), gradient: !!s.gradient })) + .sort((a, b) => a.pos - b.pos) + if (stops.length === 0) return drawBounds + + interface Run { + start: number + end: number + colorStart: number + colorEnd: number + gradient: boolean + } + const runs: Run[] = [] + for (let i = 0; i < stops.length; i++) { + const start = i === 0 ? 0 : stops[i].pos + const end = i + 1 < stops.length ? stops[i + 1].pos : 100 + if (end <= start) continue + const gradient = stops[i].gradient && i + 1 < stops.length + runs.push({ + start, + end, + colorStart: stops[i].color, + colorEnd: gradient ? stops[i + 1].color : stops[i].color, + gradient, + }) + } + if (runs.length === 0) return drawBounds + + // Single-colour fill: colour of the highest stop whose position <= value. + let singleColor = stops[0].color + for (const s of stops) if (s.pos <= valuePos) singleColor = s.color + + type RGBA = { r: number; g: number; b: number; a: number } + const lerp = (a: number, b: number, t: number): number => a + (b - a) * t + const rgbaAt = (run: Run, p: number): RGBA => { + const c0 = rgbRev(run.colorStart, true) + if (!run.gradient) return c0 + const c1 = rgbRev(run.colorEnd, true) + const span = run.end - run.start + const t = span > 0 ? Math.max(0, Math.min(1, (p - run.start) / span)) : 0 + return { r: lerp(c0.r, c1.r, t), g: lerp(c0.g, c1.g, t), b: lerp(c0.b, c1.b, t), a: lerp(c0.a, c1.a, t) } + } + const cssOf = (c: RGBA): string => `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${c.a})` + // Track (unfilled) colour. 'transparent' base colours are emitted at full alpha and composited + // through a temporary layer at trackAmount; 'dimmed' darkens the colour in place. + const trackTransform = (c: RGBA): RGBA => { + if (trackStyle === 'transparent') return c + return { r: c.r * trackAmount, g: c.g * trackAmount, b: c.b * trackAmount, a: c.a } + } + + // --- Geometry helpers shared by fill, track and marker passes. --- + const isRing = orientation === 'ring' + const isHorizontal = orientation === 'horizontal' + + // Cross-axis geometry. The fill (indicator) uses the FULL cross-axis; trackWidth only + // narrows the unfilled track, so the fill can be drawn wider than the track. + const crossFull = isHorizontal ? height : width + const fillHalf = crossFull / 2 + const trackHalf = (crossFull * trackWidth) / 2 + const bandCenter = isHorizontal ? y + height / 2 : x + width / 2 + + const posToX = (p: number): number => (reverse ? maxX - (p / 100) * width : x + (p / 100) * width) + const posToY = (p: number): number => (reverse ? y + (p / 100) * height : maxY - (p / 100) * height) + + // Ring geometry. + const cx = x + width / 2 + const cy = y + height / 2 + const outerRadius = Math.min(width, height) / 2 + const ringWidthPx = outerRadius * (element.ringWidth / 100) + const arcRadius = outerRadius - ringWidthPx / 2 + // Ring stroke widths: fill uses the full ring width; the track is narrowed by trackWidth. + const fillStrokePx = ringWidthPx + const trackStrokePx = ringWidthPx * trackWidth + // Arc span: clockwise from startAngle to endAngle (degrees, 0 = top). 0 span → full circle. + const startAngleDeg = finite(element.startAngle, 0) + const endAngleDeg = finite(element.endAngle, 360) + let sweepDeg = (((endAngleDeg - startAngleDeg) % 360) + 360) % 360 + if (sweepDeg === 0) sweepDeg = 360 + const degToRad = (deg: number): number => -Math.PI / 2 + (deg * Math.PI) / 180 + // p=0 at startAngle, p=100 at endAngle (clockwise). reverse flips which end is p=0. + const posToAngle = (p: number): number => degToRad(startAngleDeg + (reverse ? 1 - p / 100 : p / 100) * sweepDeg) + + // Paint a single position-space interval [a, b] with one solid colour onto `target`. + // `wide` selects the fill width (full) vs the narrowed track width. + const paintSolid = (target: ImageBase, a: number, b: number, color: string, wide: boolean): void => { + if (b - a <= 1e-6) return + if (isRing) { + const r1 = posToAngle(a) + const r2 = posToAngle(b) + target.arcStroke(cx, cy, arcRadius, Math.min(r1, r2), Math.max(r1, r2), false, { + color, + width: wide ? fillStrokePx : trackStrokePx, + }) + } else { + const half = wide ? fillHalf : trackHalf + const lo = bandCenter - half + const hi = bandCenter + half + if (isHorizontal) { + const xa = posToX(a) + const xb = posToX(b) + target.box(Math.round(Math.min(xa, xb)), lo, Math.round(Math.max(xa, xb)), hi, color) + } else { + const ya = posToY(a) + const yb = posToY(b) + target.box(lo, Math.round(Math.min(ya, yb)), hi, Math.round(Math.max(ya, yb)), color) + } + } + } + + // Approximate the pixel length of an interval, to choose a gradient sub-step count. + const pixelLen = (a: number, b: number): number => { + if (isRing) return arcRadius * Math.abs(posToAngle(b) - posToAngle(a)) + return isHorizontal ? Math.abs(posToX(b) - posToX(a)) : Math.abs(posToY(b) - posToY(a)) + } + + // Paint an interval [a, b] of a run onto `target`, applying a colour transform. + const paintRunInterval = ( + target: ImageBase, + a: number, + b: number, + run: Run, + transform: (c: RGBA) => RGBA, + wide: boolean + ): void => { + if (b - a <= 1e-6) return + const steps = run.gradient ? Math.max(1, Math.min(64, Math.ceil(pixelLen(a, b) / 2))) : 1 + for (let s = 0; s < steps; s++) { + const sa = a + ((b - a) * s) / steps + const sb = a + ((b - a) * (s + 1)) / steps + paintSolid(target, sa, sb, cssOf(transform(rgbaAt(run, (sa + sb) / 2))), wide) + } + } + + await img.usingTemporaryLayer(element.opacity, async (layer) => { + await layer.usingRotation(drawBounds, element.rotation, async () => { + // Whether the ring forms a complete circle (no ends to round). + const fullCircle = isRing && sweepDeg >= 360 && fillLo <= 1e-6 && fillHi >= 100 - 1e-6 + const partialRing = isRing && sweepDeg < 360 + + // --- Track pass: the parts of each run NOT covered by the fill. --- + const paintTrack = (target: ImageBase) => { + for (const run of runs) { + const leftHi = Math.min(run.end, hasFill ? fillLo : run.end) + if (leftHi > run.start) paintRunInterval(target, run.start, leftHi, run, trackTransform, false) + if (hasFill) { + const rightLo = Math.max(run.start, fillHi) + if (run.end > rightLo) paintRunInterval(target, rightLo, run.end, run, trackTransform, false) + } + } + + // On a partial ring the open track ends follow the rounded-ends flag. + if (partialRing && element.roundedEnds) { + const r = trackStrokePx / 2 + const lastRun = runs[runs.length - 1] + const ends: Array<[number, number]> = [ + [0, runs[0].colorStart], + [100, lastRun.gradient ? lastRun.colorEnd : lastRun.colorStart], + ] + for (const [p, colorNum] of ends) { + const ang = posToAngle(p) + target.circle( + cx + arcRadius * Math.cos(ang), + cy + arcRadius * Math.sin(ang), + r, + r, + 0, + Math.PI * 2, + false, + cssOf(trackTransform(rgbRev(colorNum, true))) + ) + } + } + } + if (trackStyle === 'transparent') { + // Composite the whole track through one layer so the requested transparency is + // applied once, and anti-aliased seams between runs don't accumulate into bright lines. + await layer.usingTemporaryLayer(trackAmount, async (trackLayer) => paintTrack(trackLayer)) + } else { + paintTrack(layer) + } + + // --- Fill pass: the active portion of each run (full width). --- + if (hasFill) { + for (const run of runs) { + const aLo = Math.max(run.start, fillLo) + const aHi = Math.min(run.end, fillHi) + if (aHi <= aLo) continue + if (multiColour) { + paintRunInterval(layer, aLo, aHi, run, (c) => c, true) + } else { + paintSolid(layer, aLo, aHi, parseColor(singleColor), true) + } + } + + // Rounded ends on a ring active fill (skip when the fill is a complete circle). + if (isRing && element.roundedEnds && !fullCircle) { + const capRadius = fillStrokePx / 2 + const colorAtPos = (p: number): number => { + if (!multiColour) return singleColor + const run = runs.find((r) => p >= r.start && p <= r.end) ?? runs[runs.length - 1] + if (!run.gradient) return run.colorStart + const span = run.end - run.start + // Use whichever stop colour the position is closer to. + return span > 0 && p - run.start > span / 2 ? run.colorEnd : run.colorStart + } + for (const p of [fillLo, fillHi]) { + const ang = posToAngle(p) + layer.circle( + cx + arcRadius * Math.cos(ang), + cy + arcRadius * Math.sin(ang), + capRadius, + capRadius, + 0, + Math.PI * 2, + false, + parseColor(colorAtPos(p)) + ) + } + } + } + + // --- Marker pass: a single-colour line at the value, spanning the full fill width. --- + if (element.markerEnabled) { + const markerColor = parseColor(element.markerColor) + const markerW = Math.max(1, Math.min(100, finite(element.markerWidth, 15))) / 100 + const cap: CanvasLineCap = element.roundedEnds ? 'round' : 'butt' + // The marker follows the value: its leading edge(s). In symmetric mode that's both + // fill edges; otherwise the single value position. + const positions = symmetric ? [fillLo, fillHi] : [valuePos] + for (const rawP of positions) { + const p = Math.max(0, Math.min(100, rawP)) + if (isRing) { + // A short arc bead that follows the ring's curve, full ring width, ends matching + // the rounded-ends flag — so it reads as a slice of the fill, not a straight line. + const centerAng = posToAngle(p) + const halfAng = Math.max(1, ringWidthPx * markerW) / 2 / arcRadius + layer.arcStroke(cx, cy, arcRadius, centerAng - halfAng, centerAng + halfAng, false, { + color: markerColor, + width: ringWidthPx, + cap, + }) + } else if (isHorizontal) { + const mx = posToX(p) + layer.line(mx, bandCenter - fillHalf, mx, bandCenter + fillHalf, { + color: markerColor, + width: Math.max(1, crossFull * markerW), + cap, + }) + } else { + const my = posToY(p) + layer.line(bandCenter - fillHalf, my, bandCenter + fillHalf, my, { + color: markerColor, + width: Math.max(1, crossFull * markerW), + cap, + }) + } + } + } + }) + }) + + return drawBounds + } + /** * Draw some bounds lines over the whole image, to give a visual indicator of the selected element * Note: this intentionally overshoots everything to make it very visible diff --git a/shared-lib/lib/Model/StyleLayersModel.ts b/shared-lib/lib/Model/StyleLayersModel.ts index cc1a85fbc9..7da4ab3f74 100644 --- a/shared-lib/lib/Model/StyleLayersModel.ts +++ b/shared-lib/lib/Model/StyleLayersModel.ts @@ -212,6 +212,56 @@ export interface ButtonGraphicsReferenceElement location: ExpressionOrValue } +export interface ButtonGraphicsGaugeDrawElement + extends ButtonGraphicsDrawBase, ButtonGraphicsDrawBounds, ButtonGraphicsDrawRotation { + type: 'gauge' + value: number + min: number + max: number + origin: number + symmetric: boolean + orientation: 'horizontal' | 'vertical' | 'ring' + reverse: boolean + startAngle: number + endAngle: number + ringWidth: number + roundedEnds: boolean + fillEnabled: boolean + multiColour: boolean + stops: Record[] + markerEnabled: boolean + markerColor: number + markerWidth: number + trackStyle: 'transparent' | 'dimmed' + trackAmount: number + trackWidth: number +} + +export interface ButtonGraphicsGaugeElement + extends ButtonGraphicsElementBase, ButtonGraphicsBounds, ButtonGraphicsRotation { + type: 'gauge' + value: ExpressionOrValue + min: ExpressionOrValue + max: ExpressionOrValue + origin: ExpressionOrValue + symmetric: ExpressionOrValue + orientation: ExpressionOrValue<'horizontal' | 'vertical' | 'ring'> + reverse: ExpressionOrValue + startAngle: ExpressionOrValue + endAngle: ExpressionOrValue + ringWidth: ExpressionOrValue + roundedEnds: ExpressionOrValue + fillEnabled: ExpressionOrValue + multiColour: ExpressionOrValue + stops: ExpressionOrValue[]> + markerEnabled: ExpressionOrValue + markerColor: ExpressionOrValue + markerWidth: ExpressionOrValue + trackStyle: ExpressionOrValue<'transparent' | 'dimmed'> + trackAmount: ExpressionOrValue + trackWidth: ExpressionOrValue +} + export type SomeButtonGraphicsDrawElement = | ButtonGraphicsCanvasDrawElement | ButtonGraphicsTextDrawElement @@ -221,6 +271,7 @@ export type SomeButtonGraphicsDrawElement = | ButtonGraphicsGroupDrawElement | ButtonGraphicsCircleDrawElement | ButtonGraphicsReferenceDrawElement + | ButtonGraphicsGaugeDrawElement export type SomeButtonGraphicsElement = | ButtonGraphicsCanvasElement @@ -232,3 +283,4 @@ export type SomeButtonGraphicsElement = | ButtonGraphicsCircleElement | ButtonGraphicsCompositeElement | ButtonGraphicsReferenceElement + | ButtonGraphicsGaugeElement diff --git a/tools/generate_graphics_types.mts b/tools/generate_graphics_types.mts index 39836fb2ef..9c042d2760 100644 --- a/tools/generate_graphics_types.mts +++ b/tools/generate_graphics_types.mts @@ -48,6 +48,10 @@ function convertFieldType(field: SomeCompanionInputField, isExpressionable: bool case 'internal:vertical-alignment': tsType = 'VerticalAlignment' break + case 'internal:table': + case 'internal:list': + tsType = 'Record[]' + break default: // assertNever(field.type) throw new Error(`Unhandled field type: ${field.type}`) diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Buttons.tsx b/webui/src/Buttons/EditButton/LayeredButtonEditor/Buttons.tsx index 32db868067..3b73bd6458 100644 --- a/webui/src/Buttons/EditButton/LayeredButtonEditor/Buttons.tsx +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Buttons.tsx @@ -4,6 +4,7 @@ import { faCopy, faCube, faEye, + faGauge, faImage, faLayerGroup, faLink, @@ -274,6 +275,13 @@ const AddElementDropdownPopoverContent = observer(function AddElementDropdownPop label="Circle" icon={faCircle} /> +