diff --git a/companion/lib/Controls/ControlTypes/Button/LayeredButtonDrawer.ts b/companion/lib/Controls/ControlTypes/Button/LayeredButtonDrawer.ts index 818bde7ea5..6c59d6a911 100644 --- a/companion/lib/Controls/ControlTypes/Button/LayeredButtonDrawer.ts +++ b/companion/lib/Controls/ControlTypes/Button/LayeredButtonDrawer.ts @@ -5,6 +5,7 @@ import type { ControlLocation } from '@companion-app/shared/Model/Common.js' import type { ExpressionOrValue } from '@companion-app/shared/Model/Options.js' import type { SomeButtonGraphicsElement } from '@companion-app/shared/Model/StyleLayersModel.js' import { + ButtonGraphicsDecorationType, ButtonGraphicsShowStatusIcons, type DrawStyleButtonStateProps, type DrawStyleLayeredButtonModel, @@ -30,7 +31,12 @@ export interface DrawElementsVisitor { */ export interface LayeredButtonDrawerEntitySource { getLocalVariableEntities(): ControlEntityInstance[] - getFeedbackStyleOverrides(): ReadonlyMap>> + /** + * @param defaultNoTopBar the button's resolved top-bar state, so legacy feedback sizes scale correctly + */ + getFeedbackStyleOverrides( + defaultNoTopBar: boolean | undefined + ): ReadonlyMap>> } /** @@ -171,11 +177,6 @@ export class LayeredButtonDrawer { if (!element.showStatusIcons) element.showStatusIcons = { value: ButtonGraphicsShowStatusIcons.FollowDefault, isExpression: false } break - case 'image': - if (!element.fillMode.isExpression && (element.fillMode.value as string) === 'fit_or_shrink') { - element.fillMode.value = 'fit' - } - break case 'group': for (const child of element.children) { this.#normalizeLoadedElement(child) @@ -184,6 +185,25 @@ export class LayeredButtonDrawer { } } + /** + * Resolve whether this button draws without a top bar, combining the global `buttons_decoration` default + * with the button's own canvas decoration. Used to scale legacy (pre-5.0) feedback font sizes, which are + * converted relative to the available draw height. Resolves from the button's base decoration - a feedback + * that itself overrides the decoration is deliberately not accounted for (that would be a resolution cycle). + */ + resolveDefaultNoTopBar(): boolean { + const canvasElement = this.drawElementsList.find((el) => el.type === 'canvas') + const rawDecoration = + canvasElement?.type === 'canvas' && !canvasElement.decoration.isExpression + ? canvasElement.decoration.value + : ButtonGraphicsDecorationType.FollowDefault + const resolvedDecoration = + rawDecoration === ButtonGraphicsDecorationType.FollowDefault + ? this.deps.userconfig.getKey('buttons_decoration') + : rawDecoration + return resolvedDecoration !== ButtonGraphicsDecorationType.TopBar + } + /** Compute the draw style of the button. */ async getDrawStyle(): Promise { const injectedVariableValues: VariableValues = {} @@ -198,7 +218,8 @@ export class LayeredButtonDrawer { const locationStr = location ? formatLocation(location) : null - const feedbackOverrides = this.#host.entities?.getFeedbackStyleOverrides() ?? emptyFeedbackOverrides + const feedbackOverrides = + this.#host.entities?.getFeedbackStyleOverrides(this.resolveDefaultNoTopBar()) ?? emptyFeedbackOverrides const { elements, usedVariables, usedCompositeElements, referencedLocations, cyclicLocations } = await ConvertSomeButtonGraphicsElementForDrawing( diff --git a/companion/lib/Controls/ControlTypes/Button/LayeredButtonStyleEditor.ts b/companion/lib/Controls/ControlTypes/Button/LayeredButtonStyleEditor.ts index 0cbff0adf3..d85335e755 100644 --- a/companion/lib/Controls/ControlTypes/Button/LayeredButtonStyleEditor.ts +++ b/companion/lib/Controls/ControlTypes/Button/LayeredButtonStyleEditor.ts @@ -248,7 +248,7 @@ export class LayeredButtonStyleEditor extends LayeredButtonDrawer { }) const canvasElement = this.drawElementsList.find((e) => e.type === 'canvas') - const parsedStyle = ParseLegacyStyle(diff) + const parsedStyle = ParseLegacyStyle(diff, this.resolveDefaultNoTopBar()) if (parsedStyle.text.text !== undefined) { const textElement = lazyTextElement() diff --git a/companion/lib/Controls/Entities/EntityListPoolBase.ts b/companion/lib/Controls/Entities/EntityListPoolBase.ts index 164bc6faeb..007b74fb83 100644 --- a/companion/lib/Controls/Entities/EntityListPoolBase.ts +++ b/companion/lib/Controls/Entities/EntityListPoolBase.ts @@ -216,10 +216,9 @@ export abstract class ControlEntityListPoolBase { * Get all the style overrides for the layered drawing elements * @returns A map of elementId -> elementProperty -> override value */ - abstract getFeedbackStyleOverrides(): ReadonlyMap< - string, - ReadonlyMap> - > + abstract getFeedbackStyleOverrides( + defaultNoTopBar: boolean | undefined + ): ReadonlyMap>> getLocalVariableValues(): VariableValues { const entities = this.getLocalVariableEntities() @@ -315,7 +314,7 @@ export abstract class ControlEntityListPoolBase { ) { const newOverrides: FeedbackEntityStyleOverride[] = [] - const parsedStyle = ParseLegacyStyle(newProps.style) + const parsedStyle = ParseLegacyStyle(newProps.style, undefined) // Translate the old advanced feedback property lookup into the newly produced value for (const override of existingStyleOverrides) { diff --git a/companion/lib/Controls/Entities/EntityListPoolButton.ts b/companion/lib/Controls/Entities/EntityListPoolButton.ts index 94deda0f96..626855eb1d 100644 --- a/companion/lib/Controls/Entities/EntityListPoolButton.ts +++ b/companion/lib/Controls/Entities/EntityListPoolButton.ts @@ -195,7 +195,9 @@ export abstract class ButtonEntityListPoolBase extends ControlEntityListPoolBase return entityLists } - getFeedbackStyleOverrides(): ReadonlyMap>> { + getFeedbackStyleOverrides( + defaultNoTopBar: boolean | undefined + ): ReadonlyMap>> { const result = new Map>>() const pushOverride = ( @@ -266,7 +268,7 @@ export abstract class ButtonEntityListPoolBase extends ControlEntityListPoolBase const style = feedback.feedbackValue if (!style || typeof style !== 'object') break - const parsedStyle = ParseLegacyStyle(style) + const parsedStyle = ParseLegacyStyle(style, defaultNoTopBar) for (const override of overrides) { const newValue = GetLegacyStyleProperty( parsedStyle, diff --git a/companion/lib/Controls/Entities/EntityListPoolExpressionVariable.ts b/companion/lib/Controls/Entities/EntityListPoolExpressionVariable.ts index 407d0e9ea1..a49eb91845 100644 --- a/companion/lib/Controls/Entities/EntityListPoolExpressionVariable.ts +++ b/companion/lib/Controls/Entities/EntityListPoolExpressionVariable.ts @@ -91,10 +91,9 @@ export class EntityListPoolExpressionVariable extends WithEntityEditing(ControlE this.tryTriggerLocalVariablesChanged(...changedVariableEntities) } - public getFeedbackStyleOverrides(): ReadonlyMap< - string, - ReadonlyMap> - > { + public getFeedbackStyleOverrides( + _defaultNoTopBar: boolean | undefined + ): ReadonlyMap>> { return new Map() } diff --git a/companion/lib/Controls/Entities/EntityListPoolPage.ts b/companion/lib/Controls/Entities/EntityListPoolPage.ts index 67b8ff29c8..c575017129 100644 --- a/companion/lib/Controls/Entities/EntityListPoolPage.ts +++ b/companion/lib/Controls/Entities/EntityListPoolPage.ts @@ -94,10 +94,9 @@ export class EntityListPoolPage extends WithEntityEditing(ControlEntityListPoolB this.tryTriggerLocalVariablesChanged(...changedVariableEntities) } - public getFeedbackStyleOverrides(): ReadonlyMap< - string, - ReadonlyMap> - > { + public getFeedbackStyleOverrides( + _defaultNoTopBar: boolean | undefined + ): ReadonlyMap>> { return new Map() } diff --git a/companion/lib/Controls/Entities/EntityListPoolTrigger.ts b/companion/lib/Controls/Entities/EntityListPoolTrigger.ts index 88aa6988ea..cce3693d3f 100644 --- a/companion/lib/Controls/Entities/EntityListPoolTrigger.ts +++ b/companion/lib/Controls/Entities/EntityListPoolTrigger.ts @@ -112,10 +112,9 @@ export class ControlEntityListPoolTrigger extends WithEntityEditing(ControlEntit this.tryTriggerLocalVariablesChanged(...changedVariableEntities) } - public getFeedbackStyleOverrides(): ReadonlyMap< - string, - ReadonlyMap> - > { + public getFeedbackStyleOverrides( + _defaultNoTopBar: boolean | undefined + ): ReadonlyMap>> { return new Map() } diff --git a/companion/lib/Instance/Connection/PresetsLegacy.ts b/companion/lib/Instance/Connection/PresetsLegacy.ts index ca1449d866..92e6b12348 100644 --- a/companion/lib/Instance/Connection/PresetsLegacy.ts +++ b/companion/lib/Instance/Connection/PresetsLegacy.ts @@ -156,7 +156,8 @@ function ConvertPresetDefinition( const parsedStyle = ConvertLegacyStyleToElements( ConvertPresetStyleToDrawStyle(rawPreset.style), convertPresetFeedbacksToEntities(rawPreset.feedbacks, entryCtx), - rawPreset.previewStyle + rawPreset.previewStyle, + undefined ) const presetDefinition: PresetDefinition = { diff --git a/companion/lib/Instance/Connection/Thread/Presets.ts b/companion/lib/Instance/Connection/Thread/Presets.ts index 7b26cb91d9..d76e0dd644 100644 --- a/companion/lib/Instance/Connection/Thread/Presets.ts +++ b/companion/lib/Instance/Connection/Thread/Presets.ts @@ -316,7 +316,8 @@ function ConvertPresetDefinition( const parsedStyle = ConvertLegacyStyleToElements( ConvertPresetStyleToDrawStyle(rawPreset.style), convertPresetFeedbacksToEntities(rawPreset.feedbacks, entryCtx), - rawPreset.previewStyle + rawPreset.previewStyle, + undefined ) const { steps, hasRotaryActions } = ConvertStepsForPreset(entryCtx, rawPreset.steps) diff --git a/companion/lib/Instance/Definitions.ts b/companion/lib/Instance/Definitions.ts index cd8280df02..84e6bab85a 100644 --- a/companion/lib/Instance/Definitions.ts +++ b/companion/lib/Instance/Definitions.ts @@ -244,7 +244,7 @@ export class InstanceDefinitions extends EventEmitter if (layeredStyleSelectedElementIds) { if (definition.feedbackType === FeedbackEntitySubType.Boolean && definition.feedbackStyle) { - const parsedStyle = ParseLegacyStyle(definition.feedbackStyle) + const parsedStyle = ParseLegacyStyle(definition.feedbackStyle, undefined) feedback.styleOverrides = ConvertBooleanFeedbackStyleToOverrides( parsedStyle, layeredStyleSelectedElementIds diff --git a/companion/lib/Preview/ElementStream.ts b/companion/lib/Preview/ElementStream.ts index 8ac72e4c5a..a86f539953 100644 --- a/companion/lib/Preview/ElementStream.ts +++ b/companion/lib/Preview/ElementStream.ts @@ -240,7 +240,7 @@ export class PreviewElementStream { } } - const feedbackOverrides = control.entities.getFeedbackStyleOverrides() + const feedbackOverrides = control.entities.getFeedbackStyleOverrides(control.drawing?.resolveDefaultNoTopBar()) if (!elementDef) { return { diff --git a/companion/lib/Resources/ConvertLegacyStyleToElements.ts b/companion/lib/Resources/ConvertLegacyStyleToElements.ts index 1d7ec10bc9..21e1677d0f 100644 --- a/companion/lib/Resources/ConvertLegacyStyleToElements.ts +++ b/companion/lib/Resources/ConvertLegacyStyleToElements.ts @@ -52,7 +52,10 @@ interface ParsedLegacyStyle { const TEXT_SIZE_SCALE_NO_TOPBAR = 1 / 0.6 // When no topbar const TEXT_SIZE_SCALE = 2.1 // When with topbar -export function ParseLegacyStyle(style: Partial, defaultNoTopBar?: boolean): ParsedLegacyStyle { +export function ParseLegacyStyle( + style: Partial, + defaultNoTopBar: boolean | undefined +): ParsedLegacyStyle { let textSize: number | undefined = undefined let textSizeAllowShrink: boolean | undefined = undefined if (style.size !== undefined) { @@ -63,8 +66,7 @@ export function ParseLegacyStyle(style: Partial, defaultN const n = Number(style.size) if (!isNaN(n)) { // We can't be 100% accurate on whether to account for the top-bar or not, but during imports we want to try to match how it was just drawing - const showTopBar = - defaultNoTopBar !== undefined && typeof style.show_topbar === 'boolean' ? style.show_topbar : !defaultNoTopBar + const showTopBar = typeof style.show_topbar === 'boolean' ? style.show_topbar : !defaultNoTopBar const scale = showTopBar ? TEXT_SIZE_SCALE : TEXT_SIZE_SCALE_NO_TOPBAR // Ensure is a number, and round to 1dp @@ -210,7 +212,7 @@ export function ConvertLegacyStyleToElements( style: ButtonStyleProperties, feedbacks: SomeEntityModel[], previewStyle: Partial | null | undefined, - defaultNoTopBar = false + defaultNoTopBar: boolean | undefined ): { layers: SomeButtonGraphicsElement[] feedbacks: SomeEntityModel[] @@ -256,7 +258,7 @@ export function ConvertLegacyStyleToElements( base64Image: { value: null, isExpression: false }, halign: { value: 'center', isExpression: false }, valign: { value: 'center', isExpression: false }, - fillMode: { value: 'fit', isExpression: false }, + fillMode: { value: 'fit_or_shrink', isExpression: false }, } const textElement: ButtonGraphicsTextElement = { id: 'text0', @@ -302,6 +304,10 @@ export function ConvertLegacyStyleToElements( // Apply the old style properties to the new elements const parsedStyle = ParseLegacyStyle(style, defaultNoTopBar) + // Feedback/preview styles rarely carry their own show_topbar, so scale their legacy font sizes relative to + // THIS button's resolved top-bar state (its own show_topbar, else the passed default) rather than the raw default. + const resolvedNoTopBar = typeof style.show_topbar === 'boolean' ? !style.show_topbar : defaultNoTopBar + if (parsedStyle.text.text !== undefined) textElement.text = parsedStyle.text.text if (parsedStyle.text.size !== undefined) { textElement.fontsize.value = parsedStyle.text.size @@ -338,7 +344,7 @@ export function ConvertLegacyStyleToElements( if ('style' in fb && fb.style && (Object.keys(fb.style).length > 0 || fb.connectionId !== 'internal')) { // Must be boolean, translate the props as such - const parsedStyle = ParseLegacyStyle(fb.style, defaultNoTopBar) + const parsedStyle = ParseLegacyStyle(fb.style, resolvedNoTopBar) overrides = ConvertBooleanFeedbackStyleToOverrides(parsedStyle, selectedElementIds) @@ -380,7 +386,7 @@ export function ConvertLegacyStyleToElements( const previewStyleFeedbacks: SomeEntityModel[] = [] if (previewStyle) { - const parsedStyle = ParseLegacyStyle(previewStyle, defaultNoTopBar) + const parsedStyle = ParseLegacyStyle(previewStyle, resolvedNoTopBar) const overrides = ConvertBooleanFeedbackStyleToOverrides(parsedStyle, selectedElementIds) if (overrides.length > 0) { diff --git a/companion/test/Controls/Entities/EntityListPool.test.ts b/companion/test/Controls/Entities/EntityListPool.test.ts index 5ec2f9bf34..6be7502d26 100644 --- a/companion/test/Controls/Entities/EntityListPool.test.ts +++ b/companion/test/Controls/Entities/EntityListPool.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import type { ButtonModelBase } from '@companion-app/shared/Model/ButtonModel.js' +import { EntityModelType, FeedbackEntitySubType } from '@companion-app/shared/Model/EntityModel.js' import type { ControlEntityListChangeProps } from '../../../lib/Controls/Entities/EntityListPoolBase.js' import { ControlEntityListPoolButton, @@ -355,7 +356,7 @@ describe('EntityListPool - getFeedbackStyleOverrides (layered button)', () => { pool.entityAdd('feedbacks', null, feedback) pool.updateFeedbackValues('conn01', feedbackValues({ [feedback.id]: true })) - const overrides = pool.getFeedbackStyleOverrides() + const overrides = pool.getFeedbackStyleOverrides(undefined) expect(overrides.get('el1')?.get('color')).toEqual({ isExpression: false, value: 0xff0000 }) }) @@ -366,7 +367,40 @@ describe('EntityListPool - getFeedbackStyleOverrides (layered button)', () => { pool.entityAdd('feedbacks', null, feedback) pool.updateFeedbackValues('conn01', feedbackValues({ [feedback.id]: false })) - expect(pool.getFeedbackStyleOverrides().size).toBe(0) + expect(pool.getFeedbackStyleOverrides(undefined).size).toBe(0) + }) + + test('legacy advanced-feedback font size scales by the resolved top-bar state', () => { + const { pool } = createPool({ + isLayered: true, + getEntityDefinition: (entityType) => + entityType === EntityModelType.Feedback + ? ({ entityType, feedbackType: FeedbackEntitySubType.Advanced } as any) + : ({ entityType } as any), + }) + const feedback = feedbackModel({ + styleOverrides: [ + { + overrideId: 'ov1', + elementId: 'text0', + elementProperty: 'fontsize', + override: { isExpression: false, value: 'size' }, + }, + ], + }) + pool.entityAdd('feedbacks', null, feedback) + pool.updateFeedbackValues('conn01', feedbackValues({ [feedback.id]: { size: 14 } })) + + // No top bar → full draw height → smaller percentage (the bug: this used to always be 29.4) + expect(pool.getFeedbackStyleOverrides(true).get('text0')?.get('fontsize')).toEqual({ + isExpression: false, + value: 23.3, + }) + // Top bar → reduced draw height → larger percentage + expect(pool.getFeedbackStyleOverrides(false).get('text0')?.get('fontsize')).toEqual({ + isExpression: false, + value: 29.4, + }) }) }) diff --git a/companion/test/Controls/Entities/EntityListPoolExpressionVariable.test.ts b/companion/test/Controls/Entities/EntityListPoolExpressionVariable.test.ts index cc705fe712..a2cf4a2ec0 100644 --- a/companion/test/Controls/Entities/EntityListPoolExpressionVariable.test.ts +++ b/companion/test/Controls/Entities/EntityListPoolExpressionVariable.test.ts @@ -117,6 +117,6 @@ describe('EntityListPoolExpressionVariable', () => { test('getFeedbackStyleOverrides returns an empty map', () => { const { pool } = createExpressionVariablePool() - expect(pool.getFeedbackStyleOverrides().size).toBe(0) + expect(pool.getFeedbackStyleOverrides(undefined).size).toBe(0) }) }) diff --git a/companion/test/Controls/Entities/EntityListPoolTrigger.test.ts b/companion/test/Controls/Entities/EntityListPoolTrigger.test.ts index f0c1475894..e385a448de 100644 --- a/companion/test/Controls/Entities/EntityListPoolTrigger.test.ts +++ b/companion/test/Controls/Entities/EntityListPoolTrigger.test.ts @@ -154,6 +154,6 @@ describe('ControlEntityListPoolTrigger', () => { test('getFeedbackStyleOverrides returns an empty map', () => { const { pool } = createTriggerPool() - expect(pool.getFeedbackStyleOverrides().size).toBe(0) + expect(pool.getFeedbackStyleOverrides(undefined).size).toBe(0) }) }) diff --git a/companion/test/Graphics/LayeredRenderer.test.ts b/companion/test/Graphics/LayeredRenderer.test.ts index f85f92928f..c9263bdeda 100644 --- a/companion/test/Graphics/LayeredRenderer.test.ts +++ b/companion/test/Graphics/LayeredRenderer.test.ts @@ -986,6 +986,68 @@ describe('GraphicsLayeredButtonRenderer', () => { await expect(img.canvasImage).toMatchImageSnapshot() }) + test('image fillMode=fit_or_shrink - small image kept at designed size, centered (not enlarged)', async () => { + const img = Image.create(72, 58, 1, null) + await GraphicsLayeredButtonRenderer.draw( + img, + makeStyle({ + ...drawOpts, + elements: [makeImageElement(makeDataUrl(30, 30, '#ff6600'), { fillMode: 'fit_or_shrink' })], + }), + new Set(), + null, + DEFAULT_PADDING + ) + await expect(img.canvasImage).toMatchImageSnapshot() + }) + + test('image fillMode=fit_or_shrink - with topbar, small image stays native and centered below the bar', async () => { + const img = Image.create(72, 72, 1, null) + await GraphicsLayeredButtonRenderer.draw( + img, + makeStyle({ + decoration: ButtonGraphicsDecorationType.TopBar, + show_status_icons: false, + elements: [makeImageElement(makeDataUrl(30, 30, '#ff6600'), { fillMode: 'fit_or_shrink' })], + }), + new Set(), + null, + DEFAULT_PADDING + ) + await expect(img.canvasImage).toMatchImageSnapshot() + }) + + test('image fillMode=fit_or_shrink - with topbar, image taller than the content area shrinks to fit', async () => { + const img = Image.create(72, 72, 1, null) + await GraphicsLayeredButtonRenderer.draw( + img, + makeStyle({ + decoration: ButtonGraphicsDecorationType.TopBar, + show_status_icons: false, + elements: [makeImageElement(makeDataUrl(60, 60, '#ff6600'), { fillMode: 'fit_or_shrink' })], + }), + new Set(), + null, + DEFAULT_PADDING + ) + await expect(img.canvasImage).toMatchImageSnapshot() + }) + + test('image fillMode=fit_or_shrink - oversized image shrinks to fit', async () => { + const img = Image.create(72, 58, 1, null) + await GraphicsLayeredButtonRenderer.draw( + img, + makeStyle({ + ...drawOpts, + elements: [makeImageElement(makeDataUrl(200, 100, '#ff6600'), { fillMode: 'fit_or_shrink' })], + }), + new Set(), + null, + DEFAULT_PADDING + ) + await expect(img.canvasImage).toMatchImageSnapshot() + }) + test('image with rotation', async () => { const img = Image.create(72, 58, 1, null) await GraphicsLayeredButtonRenderer.draw( diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_oversized_image_shrinks_to_fit.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_oversized_image_shrinks_to_fit.png new file mode 100644 index 0000000000..20febcd12f Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_oversized_image_shrinks_to_fit.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_small_image_kept_at_designed_size_centered_not_enlarged.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_small_image_kept_at_designed_size_centered_not_enlarged.png new file mode 100644 index 0000000000..392a3eb69f Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_small_image_kept_at_designed_size_centered_not_enlarged.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_with_topbar_image_taller_than_the_content_area_shrinks_to_fit.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_with_topbar_image_taller_than_the_content_area_shrinks_to_fit.png new file mode 100644 index 0000000000..17f53c1510 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_with_topbar_image_taller_than_the_content_area_shrinks_to_fit.png differ diff --git a/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_with_topbar_small_image_stays_native_and_centered_below_the_bar.png b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_with_topbar_small_image_stays_native_and_centered_below_the_bar.png new file mode 100644 index 0000000000..a0543fe028 Binary files /dev/null and b/companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_image_properties_image_fillMode_fit_or_shrink_-_with_topbar_small_image_stays_native_and_centered_below_the_bar.png differ diff --git a/companion/test/Resources/ConvertLegacyStyleToElements.test.ts b/companion/test/Resources/ConvertLegacyStyleToElements.test.ts index 658660e595..bdc8d5db4e 100644 --- a/companion/test/Resources/ConvertLegacyStyleToElements.test.ts +++ b/companion/test/Resources/ConvertLegacyStyleToElements.test.ts @@ -88,7 +88,7 @@ function makeAction(): SomeEntityModel { describe('ParseLegacyStyle', () => { test('empty style returns all undefined fields', () => { - const result = ParseLegacyStyle({}) + const result = ParseLegacyStyle({}, undefined) expect(result.text.text).toBeUndefined() expect(result.text.size).toBeUndefined() expect(result.text.sizeAllowShrink).toBeUndefined() @@ -104,7 +104,7 @@ describe('ParseLegacyStyle', () => { }) test('size: "auto" sets size to FONTSIZE_SHRINK_DEFAULT and sizeAllowShrink to true', () => { - const result = ParseLegacyStyle({ size: 'auto' }) + const result = ParseLegacyStyle({ size: 'auto' }, undefined) expect(result.text.size).toBe(FONTSIZE_SHRINK_DEFAULT) expect(result.text.sizeAllowShrink).toBe(true) }) @@ -132,57 +132,67 @@ describe('ParseLegacyStyle', () => { expect(r2.text.sizeAllowShrink).toBe(false) }) + test('explicit show_topbar wins even when defaultNoTopBar is omitted', () => { + // Regression: an omitted defaultNoTopBar used to force the topbar scale, discarding show_topbar:false + expect(ParseLegacyStyle({ size: 14, show_topbar: false }, undefined).text.size).toBe(23.3) // 14 * (1/0.6) + expect(ParseLegacyStyle({ size: 14, show_topbar: true }, undefined).text.size).toBe(29.4) // 14 * 2.1 + }) + test('alignment string is parsed into halign and valign', () => { - const result = ParseLegacyStyle({ alignment: 'left:top' }) + const result = ParseLegacyStyle({ alignment: 'left:top' }, undefined) expect(result.text.halign).toBe('left') expect(result.text.valign).toBe('top') }) test('pngalignment string is parsed into image halign and valign', () => { - const result = ParseLegacyStyle({ pngalignment: 'right:bottom' }) + const result = ParseLegacyStyle({ pngalignment: 'right:bottom' }, undefined) expect(result.image.halign).toBe('right') expect(result.image.valign).toBe('bottom') }) test('show_topbar:true → decoration TopBar', () => { - expect(ParseLegacyStyle({ show_topbar: true }).canvas.decoration).toBe(ButtonGraphicsDecorationType.TopBar) + expect(ParseLegacyStyle({ show_topbar: true }, undefined).canvas.decoration).toBe( + ButtonGraphicsDecorationType.TopBar + ) }) test('show_topbar:false → decoration Border', () => { - expect(ParseLegacyStyle({ show_topbar: false }).canvas.decoration).toBe(ButtonGraphicsDecorationType.Border) + expect(ParseLegacyStyle({ show_topbar: false }, undefined).canvas.decoration).toBe( + ButtonGraphicsDecorationType.Border + ) }) test('show_topbar:"default" → decoration FollowDefault', () => { - expect(ParseLegacyStyle({ show_topbar: 'default' }).canvas.decoration).toBe( + expect(ParseLegacyStyle({ show_topbar: 'default' }, undefined).canvas.decoration).toBe( ButtonGraphicsDecorationType.FollowDefault ) }) test('png64 without data: prefix gets the prefix added', () => { - expect(ParseLegacyStyle({ png64: 'abc123' }).image.image).toBe('data:image/png;base64,abc123') + expect(ParseLegacyStyle({ png64: 'abc123' }, undefined).image.image).toBe('data:image/png;base64,abc123') }) test('png64 with data: prefix is left unchanged', () => { const url = 'data:image/png;base64,xyz' - expect(ParseLegacyStyle({ png64: url }).image.image).toBe(url) + expect(ParseLegacyStyle({ png64: url }, undefined).image.image).toBe(url) }) test('png64: null returns image.image = null', () => { - expect(ParseLegacyStyle({ png64: null }).image.image).toBeNull() + expect(ParseLegacyStyle({ png64: null }, undefined).image.image).toBeNull() }) test('text with textExpression:false', () => { - const result = ParseLegacyStyle({ text: 'hello', textExpression: false }) + const result = ParseLegacyStyle({ text: 'hello', textExpression: false }, undefined) expect(result.text.text).toEqual({ isExpression: false, value: 'hello' }) }) test('text with textExpression:true', () => { - const result = ParseLegacyStyle({ text: '$(var:x)', textExpression: true }) + const result = ParseLegacyStyle({ text: '$(var:x)', textExpression: true }, undefined) expect(result.text.text).toEqual({ isExpression: true, value: '$(var:x)' }) }) test('color and bgcolor are passed through directly', () => { - const result = ParseLegacyStyle({ color: 0xffffff, bgcolor: 0x112233 }) + const result = ParseLegacyStyle({ color: 0xffffff, bgcolor: 0x112233 }, undefined) expect(result.text.color).toBe(0xffffff) expect(result.background.color).toBe(0x112233) }) @@ -192,7 +202,7 @@ describe('ParseLegacyStyle', () => { describe('GetLegacyStyleProperty', () => { test('text property returns the text value', () => { - const parsed = ParseLegacyStyle({ text: 'hello' }) + const parsed = ParseLegacyStyle({ text: 'hello' }, undefined) expect(GetLegacyStyleProperty(parsed, {}, 'text', '')).toEqual({ isExpression: false, value: 'hello' }) }) @@ -209,39 +219,39 @@ describe('GetLegacyStyleProperty', () => { }) test('size="auto" (elementProperty=fontsize) returns FONTSIZE_SHRINK_DEFAULT', () => { - const parsed = ParseLegacyStyle({ size: 'auto' }) + const parsed = ParseLegacyStyle({ size: 'auto' }, undefined) const result = GetLegacyStyleProperty(parsed, {}, 'size', 'fontsize') expect(result).toEqual({ isExpression: false, value: 100 }) }) test('size="auto" (elementProperty=fontsizeAllowShrink) returns true', () => { - const parsed = ParseLegacyStyle({ size: 'auto' }) + const parsed = ParseLegacyStyle({ size: 'auto' }, undefined) const result = GetLegacyStyleProperty(parsed, {}, 'size', 'fontsizeAllowShrink') expect(result).toEqual({ isExpression: false, value: true }) }) test('color property', () => { - const parsed = ParseLegacyStyle({ color: 0x123456 }) + const parsed = ParseLegacyStyle({ color: 0x123456 }, undefined) expect(GetLegacyStyleProperty(parsed, {}, 'color', '')).toEqual({ isExpression: false, value: 0x123456 }) }) test('bgcolor property', () => { - const parsed = ParseLegacyStyle({ bgcolor: 0xabcdef }) + const parsed = ParseLegacyStyle({ bgcolor: 0xabcdef }, undefined) expect(GetLegacyStyleProperty(parsed, {}, 'bgcolor', '')).toEqual({ isExpression: false, value: 0xabcdef }) }) test('alignment → halign with elementProperty=halign', () => { - const parsed = ParseLegacyStyle({ alignment: 'left:top' }) + const parsed = ParseLegacyStyle({ alignment: 'left:top' }, undefined) expect(GetLegacyStyleProperty(parsed, {}, 'alignment', 'halign')).toEqual({ isExpression: false, value: 'left' }) }) test('alignment → valign with elementProperty=valign', () => { - const parsed = ParseLegacyStyle({ alignment: 'left:top' }) + const parsed = ParseLegacyStyle({ alignment: 'left:top' }, undefined) expect(GetLegacyStyleProperty(parsed, {}, 'alignment', 'valign')).toEqual({ isExpression: false, value: 'top' }) }) test('png64 property returns image.image value', () => { - const parsed = ParseLegacyStyle({ png64: 'data:image/png;base64,abc' }) + const parsed = ParseLegacyStyle({ png64: 'data:image/png;base64,abc' }, undefined) expect(GetLegacyStyleProperty(parsed, {}, 'png64', '')).toEqual({ isExpression: false, value: 'data:image/png;base64,abc', @@ -249,13 +259,13 @@ describe('GetLegacyStyleProperty', () => { }) test('returns undefined when property value is absent', () => { - const parsed = ParseLegacyStyle({}) + const parsed = ParseLegacyStyle({}, undefined) expect(GetLegacyStyleProperty(parsed, {}, 'text', '')).toBeUndefined() expect(GetLegacyStyleProperty(parsed, {}, 'size', '')).toBeUndefined() }) test('returns undefined for an unknown property', () => { - const parsed = ParseLegacyStyle({ text: 'x' }) + const parsed = ParseLegacyStyle({ text: 'x' }, undefined) expect(GetLegacyStyleProperty(parsed, {}, 'unknown_prop', '')).toBeUndefined() }) }) @@ -264,30 +274,55 @@ describe('GetLegacyStyleProperty', () => { describe('ConvertLegacyStyleToElements', () => { test('always produces 4 base layers', () => { - const { layers } = ConvertLegacyStyleToElements(minimalStyle, [], null) + const { layers } = ConvertLegacyStyleToElements(minimalStyle, [], null, undefined) expect(layers).toHaveLength(4) expect(layers.map((l) => l.id)).toEqual(['canvas', 'box0', 'image0', 'text0']) }) + test('image element defaults to fit_or_shrink so small icons are not enlarged', () => { + const { layers } = ConvertLegacyStyleToElements(minimalStyle, [], null, undefined) + const imageLayer = layers.find((l) => l.id === 'image0') as any + expect(imageLayer.fillMode).toEqual({ value: 'fit_or_shrink', isExpression: false }) + }) + test('advanced feedback adds a 5th bufferElement layer', () => { - const { layers } = ConvertLegacyStyleToElements(minimalStyle, [makeAdvancedFeedback()], null) + const { layers } = ConvertLegacyStyleToElements(minimalStyle, [makeAdvancedFeedback()], null, undefined) expect(layers).toHaveLength(5) expect(layers[4].id).toBe('imageBuffers') }) test('boolean feedback with style sets styleOverrides and removes style', () => { - const { feedbacks } = ConvertLegacyStyleToElements(minimalStyle, [makeBooleanFeedback()], null) + const { feedbacks } = ConvertLegacyStyleToElements(minimalStyle, [makeBooleanFeedback()], null, undefined) expect(feedbacks[0]).toHaveProperty('styleOverrides') expect((feedbacks[0] as any).style).toBeUndefined() }) test('boolean feedback styleOverrides include a color override', () => { - const { feedbacks } = ConvertLegacyStyleToElements(minimalStyle, [makeBooleanFeedback({ bgcolor: 0xff0000 })], null) + const { feedbacks } = ConvertLegacyStyleToElements( + minimalStyle, + [makeBooleanFeedback({ bgcolor: 0xff0000 })], + null, + undefined + ) const overrides = (feedbacks[0] as FeedbackEntityModel).styleOverrides! const colorOverride = overrides.find((o) => o.elementProperty === 'color' && o.elementId === 'box0') expect(colorOverride?.override).toEqual({ isExpression: false, value: 0xff0000 }) }) + test("feedback font size inherits the button's own show_topbar, not just the global default", () => { + // Global default says top bar shown (defaultNoTopBar=false), but the button hides it → feedback size + // must scale by the no-top-bar factor, matching the button it draws on. + const { feedbacks } = ConvertLegacyStyleToElements( + { ...minimalStyle, show_topbar: false }, + [makeBooleanFeedback({ size: 14 })], + null, + false + ) + const overrides = (feedbacks[0] as FeedbackEntityModel).styleOverrides! + const fontsize = overrides.find((o) => o.elementProperty === 'fontsize') + expect(fontsize?.override).toEqual({ isExpression: false, value: 23.3 }) // 14 * (1/0.6), not 14 * 2.1 + }) + test('feedback that already has styleOverrides is passed through unchanged', () => { const feedback: FeedbackEntityModel = { type: EntityModelType.Feedback, @@ -300,24 +335,24 @@ describe('ConvertLegacyStyleToElements', () => { { overrideId: 'existing', elementId: 'x', elementProperty: 'y', override: { isExpression: false, value: 'z' } }, ], } - const { feedbacks } = ConvertLegacyStyleToElements(minimalStyle, [feedback], null) + const { feedbacks } = ConvertLegacyStyleToElements(minimalStyle, [feedback], null, undefined) expect((feedbacks[0] as FeedbackEntityModel).styleOverrides).toHaveLength(1) expect((feedbacks[0] as FeedbackEntityModel).styleOverrides![0].overrideId).toBe('existing') }) test('non-feedback entity is passed through unchanged', () => { const action = makeAction() - const { feedbacks } = ConvertLegacyStyleToElements(minimalStyle, [action], null) + const { feedbacks } = ConvertLegacyStyleToElements(minimalStyle, [action], null, undefined) expect(feedbacks[0]).toEqual(action) }) test('previewStyle null results in empty previewStyleFeedbacks', () => { - const { previewStyleFeedbacks } = ConvertLegacyStyleToElements(minimalStyle, [], null) + const { previewStyleFeedbacks } = ConvertLegacyStyleToElements(minimalStyle, [], null, undefined) expect(previewStyleFeedbacks).toEqual([]) }) test('previewStyle with a property creates a previewStyleFeedback entry', () => { - const { previewStyleFeedbacks } = ConvertLegacyStyleToElements(minimalStyle, [], { bgcolor: 0x0000ff }) + const { previewStyleFeedbacks } = ConvertLegacyStyleToElements(minimalStyle, [], { bgcolor: 0x0000ff }, undefined) expect(previewStyleFeedbacks).toHaveLength(1) expect(previewStyleFeedbacks[0].type).toBe(EntityModelType.Feedback) expect((previewStyleFeedbacks[0] as FeedbackEntityModel).styleOverrides).toBeDefined() @@ -325,24 +360,29 @@ describe('ConvertLegacyStyleToElements', () => { test('previewStyle with no overridable properties gives empty previewStyleFeedbacks', () => { // Empty partial style → parsedStyle has nothing set → overrides.length === 0 - const { previewStyleFeedbacks } = ConvertLegacyStyleToElements(minimalStyle, [], {}) + const { previewStyleFeedbacks } = ConvertLegacyStyleToElements(minimalStyle, [], {}, undefined) expect(previewStyleFeedbacks).toHaveLength(0) }) test('style properties are applied to the canvas decoration layer', () => { - const { layers } = ConvertLegacyStyleToElements({ ...minimalStyle, show_topbar: true }, [], null) + const { layers } = ConvertLegacyStyleToElements({ ...minimalStyle, show_topbar: true }, [], null, undefined) const canvas = layers[0] as any expect(canvas.decoration.value).toBe(ButtonGraphicsDecorationType.TopBar) }) test('style text is applied to the text element', () => { - const { layers } = ConvertLegacyStyleToElements({ ...minimalStyle, text: 'hi', textExpression: false }, [], null) + const { layers } = ConvertLegacyStyleToElements( + { ...minimalStyle, text: 'hi', textExpression: false }, + [], + null, + undefined + ) const textEl = layers.find((l) => l.id === 'text0') as any expect(textEl.text).toEqual({ isExpression: false, value: 'hi' }) }) test('style bgcolor is applied to the background element', () => { - const { layers } = ConvertLegacyStyleToElements({ ...minimalStyle, bgcolor: 0xaabbcc }, [], null) + const { layers } = ConvertLegacyStyleToElements({ ...minimalStyle, bgcolor: 0xaabbcc }, [], null, undefined) const boxEl = layers.find((l) => l.id === 'box0') as any expect(boxEl.color.value).toBe(0xaabbcc) }) @@ -359,7 +399,7 @@ describe('ConvertLegacyStyleToElements', () => { children: { feedbacks: [childFeedback] }, } - const { feedbacks } = ConvertLegacyStyleToElements(minimalStyle, [conditionalFeedback], null) + const { feedbacks } = ConvertLegacyStyleToElements(minimalStyle, [conditionalFeedback], null, undefined) const updatedCond = feedbacks[0] as FeedbackEntityModel const children = updatedCond.children!['feedbacks']! expect((children[0] as FeedbackEntityModel).styleOverrides).toBeDefined() @@ -370,12 +410,12 @@ describe('ConvertLegacyStyleToElements', () => { describe('ConvertBooleanFeedbackStyleToOverrides', () => { test('returns empty array when no properties are set', () => { - const parsed = ParseLegacyStyle({}) + const parsed = ParseLegacyStyle({}, undefined) expect(ConvertBooleanFeedbackStyleToOverrides(parsed, defaultSelectedIds)).toHaveLength(0) }) test('text property creates a text override on the text element', () => { - const parsed = ParseLegacyStyle({ text: 'hello', textExpression: false }) + const parsed = ParseLegacyStyle({ text: 'hello', textExpression: false }, undefined) const overrides = ConvertBooleanFeedbackStyleToOverrides(parsed, defaultSelectedIds) const textOverride = overrides.find((o) => o.elementProperty === 'text') expect(textOverride?.elementId).toBe('text0') @@ -383,7 +423,7 @@ describe('ConvertBooleanFeedbackStyleToOverrides', () => { }) test('text color creates a color override', () => { - const parsed = ParseLegacyStyle({ color: 0xaabbcc }) + const parsed = ParseLegacyStyle({ color: 0xaabbcc }, undefined) const overrides = ConvertBooleanFeedbackStyleToOverrides(parsed, defaultSelectedIds) const colorOverride = overrides.find((o) => o.elementProperty === 'color') expect(colorOverride?.elementId).toBe('text0') @@ -391,7 +431,7 @@ describe('ConvertBooleanFeedbackStyleToOverrides', () => { }) test('text alignment creates halign and valign overrides', () => { - const parsed = ParseLegacyStyle({ alignment: 'left:top' }) + const parsed = ParseLegacyStyle({ alignment: 'left:top' }, undefined) const overrides = ConvertBooleanFeedbackStyleToOverrides(parsed, defaultSelectedIds) const halign = overrides.find((o) => o.elementProperty === 'halign' && o.elementId === 'text0') const valign = overrides.find((o) => o.elementProperty === 'valign' && o.elementId === 'text0') @@ -400,21 +440,21 @@ describe('ConvertBooleanFeedbackStyleToOverrides', () => { }) test('bgcolor creates a background color override', () => { - const parsed = ParseLegacyStyle({ bgcolor: 0x112233 }) + const parsed = ParseLegacyStyle({ bgcolor: 0x112233 }, undefined) const overrides = ConvertBooleanFeedbackStyleToOverrides(parsed, defaultSelectedIds) const colorOverride = overrides.find((o) => o.elementId === 'box0') expect(colorOverride?.override.value).toBe(0x112233) }) test('png64 creates a base64Image override on the image element', () => { - const parsed = ParseLegacyStyle({ png64: 'data:image/png;base64,abc' }) + const parsed = ParseLegacyStyle({ png64: 'data:image/png;base64,abc' }, undefined) const overrides = ConvertBooleanFeedbackStyleToOverrides(parsed, defaultSelectedIds) const imgOverride = overrides.find((o) => o.elementProperty === 'base64Image') expect(imgOverride?.elementId).toBe('image0') }) test('each override has a unique overrideId', () => { - const parsed = ParseLegacyStyle({ text: 'x', color: 0xffffff, bgcolor: 0, alignment: 'left:top' }) + const parsed = ParseLegacyStyle({ text: 'x', color: 0xffffff, bgcolor: 0, alignment: 'left:top' }, undefined) const overrides = ConvertBooleanFeedbackStyleToOverrides(parsed, defaultSelectedIds) const ids = overrides.map((o) => o.overrideId) expect(new Set(ids).size).toBe(ids.length) @@ -430,7 +470,7 @@ describe('ConvertBooleanFeedbackStyleToOverrides', () => { }) test('auto size creates fontsize=FONTSIZE_SHRINK_DEFAULT and fontsizeAllowShrink=true overrides', () => { - const parsed = ParseLegacyStyle({ size: 'auto' }) + const parsed = ParseLegacyStyle({ size: 'auto' }, undefined) const overrides = ConvertBooleanFeedbackStyleToOverrides(parsed, defaultSelectedIds) const fontsizeOverride = overrides.find((o) => o.elementProperty === 'fontsize') const allowShrinkOverride = overrides.find((o) => o.elementProperty === 'fontsizeAllowShrink') @@ -443,7 +483,7 @@ describe('ConvertBooleanFeedbackStyleToOverrides', () => { ...defaultSelectedIds, [ButtonGraphicsElementUsage.Text]: undefined, } - const parsed = ParseLegacyStyle({ text: 'hi', color: 0xffffff }) + const parsed = ParseLegacyStyle({ text: 'hi', color: 0xffffff }, undefined) const overrides = ConvertBooleanFeedbackStyleToOverrides(parsed, ids) expect(overrides.find((o) => o.elementProperty === 'text')).toBeUndefined() }) diff --git a/shared-lib/lib/Graphics/ElementPropertiesSchemas.ts b/shared-lib/lib/Graphics/ElementPropertiesSchemas.ts index eb9e90d1bd..d20badf9fa 100644 --- a/shared-lib/lib/Graphics/ElementPropertiesSchemas.ts +++ b/shared-lib/lib/Graphics/ElementPropertiesSchemas.ts @@ -254,6 +254,7 @@ export const imageElementSchema: ElementSchemaSection[] = [ { id: 'fit', label: 'Fit' }, { id: 'fill', label: 'Fill' }, { id: 'crop', label: 'Crop' }, + { id: 'fit_or_shrink', label: 'Legacy (sized as on a 72px button)' }, ], default: 'fit', }, diff --git a/shared-lib/lib/Graphics/ImageBase.ts b/shared-lib/lib/Graphics/ImageBase.ts index 7e0985d69e..5c10008660 100644 --- a/shared-lib/lib/Graphics/ImageBase.ts +++ b/shared-lib/lib/Graphics/ImageBase.ts @@ -114,6 +114,14 @@ export abstract class ImageBase) => Promise ): Promise { return this.#imagePool.usingImage(this.#textLayoutCache, async (img) => { + // Propagate the button content area so nested elements (eg inside a group) share the same reference + img.contentWidth = this.contentWidth + img.contentHeight = this.contentHeight + await fcn(img) await this.usingAlpha(compositeAlpha, async () => { @@ -542,11 +564,13 @@ export abstract class ImageBase halign: ExpressionOrValue valign: ExpressionOrValue - fillMode: ExpressionOrValue<'fit' | 'fill' | 'crop'> + fillMode: ExpressionOrValue<'fit' | 'fill' | 'crop' | 'fit_or_shrink'> } export interface ButtonGraphicsBoxDrawElement