Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0e57843
wip: sketch schema
Julusian Jun 6, 2026
5199848
wip: initial table input element
Julusian Jun 6, 2026
e0e62f2
wip: drawing
Julusian Jun 6, 2026
6af69c9
wip: tests
Julusian Jun 6, 2026
fb870ca
wip: ring mode
Julusian Jun 6, 2026
cdff5e2
wip: rounded ends for ring
Julusian Jun 6, 2026
700050d
wip: fixes
Julusian Jun 6, 2026
1e3da23
wip: tests
Julusian Jun 6, 2026
415ac51
wip: fix
Julusian Jun 6, 2026
ecf2566
wip: list input
Julusian Jun 7, 2026
d92303e
wip: list polish
Julusian Jun 7, 2026
10587b3
wip: tests
Julusian Jun 7, 2026
c818247
feat: add internal:list and internal:table helper components
Julusian Jun 7, 2026
cb56910
wip
Julusian Jun 7, 2026
aa9dfb5
wip: refactor
Julusian Jun 7, 2026
4e6e9a5
wip: refactor
Julusian Jun 7, 2026
f522289
fix
Julusian Jun 7, 2026
e9550c6
fix
Julusian Jun 7, 2026
af8acfe
fix
Julusian Jun 7, 2026
df9e78b
fix
Julusian Jun 7, 2026
cfb48d9
Merge branch 'feat/internal-list-element' into feat/gauge-element
Julusian Jun 7, 2026
c082f90
fix tests
Julusian Jun 7, 2026
4f2d80c
wip: refactor
Julusian Jun 7, 2026
10167fe
restore comments
Julusian Jun 7, 2026
1f70ffe
format
Julusian Jun 7, 2026
525baad
Merge commit '1f70ffe8f192ca1d19f9895876028f4fac341638' into feat/gau…
Julusian Jun 7, 2026
843911d
Merge branch 'main' into feat/gauge-element
Julusian Jun 7, 2026
03b17b1
tidy
Julusian Jun 7, 2026
41b0d94
tidy
Julusian Jun 7, 2026
ad13b77
Merge branch 'main' into feat/gauge-element
Julusian Jun 8, 2026
59a05fa
Merge branch 'main' into feat/gauge-element
Julusian Jun 18, 2026
1726d84
wip: expand gauge
Julusian Jun 18, 2026
4d3e093
tests
Julusian Jun 18, 2026
3c02db6
review
Julusian Jun 18, 2026
e653e72
fix
Julusian Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,48 @@ 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 },
orientation: { value: 'horizontal', isExpression: false },
reverse: { value: false, isExpression: false },
roundedEnds: { value: true, isExpression: false },
thickness: { value: 20, isExpression: false },
multiSegment: { value: true, isExpression: false },
segments: {
value: [
{
_id: { value: nanoid(), isExpression: false },
value: { value: 0, isExpression: false },
color: { value: 0x00ff00, isExpression: false },
},
{
_id: { value: nanoid(), isExpression: false },
value: { value: 66, isExpression: false },
color: { value: 0xffff00, isExpression: false },
},
{
_id: { value: nanoid(), isExpression: false },
value: { value: 85, isExpression: false },
color: { value: 0xff0000, isExpression: false },
},
],
isExpression: false,
},
inactiveStyle: { value: 'transparent', isExpression: false },
inactiveAmount: { value: 70, isExpression: false },
}
case 'composite':
// Composite elements should not be created directly through this function
// They are created with custom logic in layeredStyleAddElement
Expand Down
108 changes: 97 additions & 11 deletions companion/lib/Graphics/ConvertGraphicsElements.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
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 { isExpressionOrValue, type ExpressionOrValue } from '@companion-app/shared/Model/Options.js'
import type {
ButtonGraphicsBorder,
ButtonGraphicsBounds,
Expand All @@ -17,6 +20,8 @@ import type {
ButtonGraphicsDrawBorder,
ButtonGraphicsDrawBounds,
ButtonGraphicsElementBase,
ButtonGraphicsGaugeDrawElement,
ButtonGraphicsGaugeElement,
ButtonGraphicsGroupDrawElement,
ButtonGraphicsGroupElement,
ButtonGraphicsImageDrawElement,
Expand Down Expand Up @@ -61,6 +66,29 @@ 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<T extends string>(
elementType: Parameters<typeof getElementSchemaProperty>[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<ButtonGraphicsDecorationType>('canvas', 'decoration')
const CANVAS_SHOW_STATUS_ICONS_CHOICES = dropdownChoices<ButtonGraphicsShowStatusIcons>('canvas', 'showStatusIcons')
const IMAGE_FILL_MODE_CHOICES = dropdownChoices<ButtonGraphicsImageDrawElement['fillMode']>('image', 'fillMode')
const TEXT_FONT_CHOICES = dropdownChoices<ButtonGraphicsTextDrawElement['font']>('text', 'font')
// borderPosition is shared across box/line/circle — all use the same choices from borderFields.
const BORDER_POSITION_CHOICES = dropdownChoices<ButtonGraphicsDrawBorder['borderPosition']>('box', 'borderPosition')
const GAUGE_ORIENTATION_CHOICES = dropdownChoices<ButtonGraphicsGaugeDrawElement['orientation']>('gauge', 'orientation')
const GAUGE_INACTIVE_STYLE_CHOICES = dropdownChoices<ButtonGraphicsGaugeDrawElement['inactiveStyle']>(
'gauge',
'inactiveStyle'
)

export async function ConvertSomeButtonGraphicsElementForDrawing(
compositeElementStore: InstanceDefinitions,
parser: VariablesAndExpressionParser,
Expand Down Expand Up @@ -164,6 +192,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
Expand Down Expand Up @@ -239,14 +271,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
Expand Down Expand Up @@ -508,6 +536,8 @@ function parseCompositeElementChildOptions(
case 'internal:trigger':
case 'internal:trigger_collection':
case 'internal:variable':
case 'internal:list':
case 'internal:table':
case 'secret-text':
case 'static-text':
case 'custom-variable':
Expand Down Expand Up @@ -636,7 +666,7 @@ async function convertImageElementForDrawing(
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
}

Expand Down Expand Up @@ -666,7 +696,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'),
Expand Down Expand Up @@ -779,13 +809,69 @@ 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 }

const orientation = helper.getTolerantEnum('orientation', GAUGE_ORIENTATION_CHOICES, 'horizontal')
const inactiveStyle = helper.getTolerantEnum('inactiveStyle', GAUGE_INACTIVE_STYLE_CHOICES, 'transparent')

const segmentsRaw = (element.segments as ExpressionOrValue<JsonValue[]>).value
// Resolve a cell that may be a plain JsonValue (old internal:table) or ExpressionOrValue (internal:list)
const resolveSegmentCell = (raw: unknown): number => {
if (isExpressionOrValue(raw)) {
if (raw.isExpression) {
const r = helper.executeExpressionAndTrackVariables(raw.value, 'number')
return r.ok ? Number(r.value) : 0
}
return Number(raw.value ?? 0)
}
return Number(raw ?? 0)
}
const thresholds: ButtonGraphicsGaugeDrawElement['segments'] = Array.isArray(segmentsRaw)
? segmentsRaw.map((row) => ({
value: Math.max(0, Math.min(100, resolveSegmentCell((row as any)?.value))),
color: resolveSegmentCell((row as any)?.color),
}))
: []

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: Math.round(Math.max(0, Math.min(100, helper.getNumber('value', 0))) * 10) / 10,
orientation,
reverse: helper.getBoolean('reverse', false),
roundedEnds: helper.getBoolean('roundedEnds', true),
thickness: Math.max(1, Math.min(50, helper.getNumber('thickness', 20))),
multiSegment: helper.getBoolean('multiSegment', true),
segments: thresholds,
inactiveStyle,
inactiveAmount: helper.getNumber('inactiveAmount', 70),
contentHash: '',
}

drawElement.contentHash = computeElementContentHash(drawElement)
return { drawElement, usedVariables, compositeElement: null }
}

function convertBorderProperties(
helper: ElementExpressionHelper<ButtonGraphicsBorder & ButtonGraphicsElementBase>
): 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'),
}
}

Expand Down
12 changes: 12 additions & 0 deletions companion/lib/Graphics/ConvertGraphicsElements/Helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,18 @@ export class ElementExpressionHelper<T> {
return actualValue
}

/**
* Like getEnum, but compares the string value to the enum values in a tolerant way:
* Matches only the first non-whitespace character, case-insensitively.
*/
getTolerantEnum<TVal extends string>(propertyName: keyof T, values: readonly TVal[], defaultValue: TVal): TVal {
const raw = this.getString(propertyName, defaultValue)
const trimmed = String(raw ?? '')
.trim()
.toLowerCase()
return values.find((v) => v.toLowerCase().startsWith(trimmed[0])) ?? defaultValue
}

getBoolean(propertyName: keyof T, defaultValue: boolean): boolean {
const value = this.#getValue(propertyName)

Expand Down
Loading
Loading