Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0656c7b
feat: wysiwyg toolbar for basic edits to a button's style
bryce-seifert Jul 20, 2026
04b07cf
skip reset while dragState.current is set
bryce-seifert Jul 21, 2026
efae89d
make toolbar vertical to save some space
bryce-seifert Jul 21, 2026
11e9e88
make sure snapping gets disabled state too
bryce-seifert Jul 21, 2026
06d8bbe
add tooltips about why toolbar is disabled
bryce-seifert Jul 21, 2026
c93bdc6
fix canvas size getting too big
bryce-seifert Jul 21, 2026
5db4bb7
try out a toolbar for the aspect ratio
bryce-seifert Jul 21, 2026
ac31c0a
cleaner gap between preview and list
bryce-seifert Jul 21, 2026
5e39fa6
move mode toggle to the separator
bryce-seifert Jul 21, 2026
c9a8f3d
advanced -> all properties; make buttons stand out more
bryce-seifert Aug 2, 2026
d02483b
add custom ratio option
bryce-seifert Aug 2, 2026
f6ce397
allow line endpoints to be manipulated in canvas
bryce-seifert Aug 2, 2026
59b7e20
fix: name bar disappearing in safari
bryce-seifert Aug 2, 2026
f0163f5
keep lines from distorting during move
bryce-seifert Aug 2, 2026
27a9697
fix drag listener cleanup in overlays
bryce-seifert Aug 2, 2026
7d65736
cleanup readonlyReason tooltip
bryce-seifert Aug 2, 2026
e0e6fd2
breakout SnapGuide, use % instead of pixels
bryce-seifert Aug 2, 2026
89215fe
fix: try to ensure module are shutdown cleanly when companion exits
Julusian Aug 2, 2026
596fe13
Merge branch 'main' into feat/wysiwygEditor
Julusian Aug 3, 2026
f5294ef
fix
Julusian Aug 3, 2026
51a5aa8
fix
Julusian Aug 3, 2026
9e20276
fix rotation on overlay, use renderer for element geometry
bryce-seifert Aug 5, 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
4 changes: 4 additions & 0 deletions companion/lib/Controls/ControlTypes/Button/Layered.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ export class ControlButtonLayered
return this.drawing.updateOption(id, key, newVal)
}

layeredStyleUpdateOptions(id: string, values: Record<string, ExpressionOrValue<JsonValue | undefined>>): boolean {
return this.drawing.updateOptions(id, values)
}

layeredStyleUpdateFromLegacyProperties(diff: Partial<ButtonStyleProperties>): boolean {
return this.drawing.updateFromLegacyProperties(diff, this.options.canModifyStyleInApis)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,10 @@ export class LayeredButtonStyleEditor extends LayeredButtonDrawer {
return true
}

updateOption(id: string, key: string, newVal: ExpressionOrValue<JsonValue | undefined>): boolean {
// Ignore fixed/structural properties, to avoid corrupting the layer model
if (
// Fixed/structural properties, which must never be reassigned via the generic option setters as it
// would corrupt the layer model
static #isStructuralKey(key: string): boolean {
return (
key === 'id' ||
key === 'type' ||
key === 'name' ||
Expand All @@ -215,14 +216,29 @@ export class LayeredButtonStyleEditor extends LayeredButtonDrawer {
key === 'connectionId' ||
key === 'elementId'
)
return false
}

updateOption(id: string, key: string, newVal: ExpressionOrValue<JsonValue | undefined>): boolean {
return this.updateOptions(id, { [key]: newVal })
}

/**
* Apply several option changes to an element as a single commit. Callers changing more than one key at
* once (eg a drag that moves and resizes) should use this rather than repeated {@link updateOption}, so
* the element is never persisted or redrawn in a partially-updated state.
*/
updateOptions(id: string, values: Record<string, ExpressionOrValue<JsonValue | undefined>>): boolean {
const entries = Object.entries(values).filter(([key]) => !LayeredButtonStyleEditor.#isStructuralKey(key))
if (entries.length === 0) return false

const currentElementLocation = this.#findElementIndexAndParent(this.drawElementsList, null, id)
if (!currentElementLocation) return false

const entry = currentElementLocation.element as any

entry[key] = newVal
for (const [key, newVal] of entries) {
entry[key] = newVal
}

this.elementConversionCache.queueInvalidate(id)
this.#host.commitChange(true)
Expand Down
7 changes: 7 additions & 0 deletions companion/lib/Controls/ControlTypes/Button/Preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,13 @@ export class ControlButtonPreset
throw new Error('ControlButtonPreset does not support mutations')
}

/**
* Update several options on an element from the layered style
*/
layeredStyleUpdateOptions(_id: string, _values: Record<string, ExpressionOrValue<JsonValue | undefined>>): boolean {
throw new Error('ControlButtonPreset does not support mutations')
}

/**
* Update the style from legacy properties
*/
Expand Down
8 changes: 8 additions & 0 deletions companion/lib/Controls/IControlFragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ export interface ControlWithLayeredStyle extends ControlBase<any> {
*/
layeredStyleUpdateOption(id: string, key: string, value: ExpressionOrValue<JsonValue | undefined>): boolean

/**
* Update several options on an element from the layered style, as a single commit
* @param id Element id to update
* @param values New ExpressionOrValue for each option key
* @returns true if any changes were made
*/
layeredStyleUpdateOptions(id: string, values: Record<string, ExpressionOrValue<JsonValue | undefined>>): boolean

/**
* Update the style from legacy properties
* Future: Once the old button style is removed, this should be reworked to utilise the new style system better
Expand Down
17 changes: 17 additions & 0 deletions companion/lib/Controls/StylesTrpcRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,22 @@ export function createStylesTrpcRouter(controlsMap: Map<string, SomeControl<any>

return control.layeredStyleUpdateOption(input.elementId, input.key, input.value)
}),

updateOptions: publicProcedure
.input(
z.object({
controlId: z.string(),
elementId: z.string(),
values: z.record(z.string(), ExpressionOrJsonValueSchema),
})
)
.mutation(async ({ input }) => {
const control = controlsMap.get(input.controlId)
if (!control) return false

if (!control.supportsLayeredStyle) throw new Error(`Control "${input.controlId}" does not support layer styles`)

return control.layeredStyleUpdateOptions(input.elementId, input.values)
}),
})
}
17 changes: 16 additions & 1 deletion companion/lib/Instance/Connection/Thread/Entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,22 @@ const ipcWrapper = new IpcWrapper<ModuleToHostEventsNew, HostToModuleEventsNew>(
// The 'ipc' channel is retained only for the disconnect signal below.
const channel = new FramedChannel(dataSocket, (msg) => ipcWrapper.receivedMessage(msg as any))

process.on('disconnect', () => process.exit())
// Safety net: if the parent dies/crashes without sending 'destroy', the IPC channel
// closes. Since we now ignore signals, this is the only thing that reaps us — attempt a
// best-effort surface reset, then exit. Hard-bounded so a hung destroy still exits.
let disconnecting = false
process.on('disconnect', () => {
if (disconnecting) return
disconnecting = true
const forceExit = setTimeout(() => process.exit(1), 2000)
forceExit.unref?.()
Promise.resolve()
.then(async () => {
if (instance && instanceInitialized) await instance.destroy()
})
.catch(() => {})
.finally(() => process.exit(1))
})

registerLoggingSink((source, level, message) => {
ipcWrapper.sendWithNoCb('log-message', {
Expand Down
17 changes: 16 additions & 1 deletion companion/lib/Instance/Surface/Thread/Entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,22 @@ const ipcWrapper = new IpcWrapper<SurfaceModuleToHostEvents, HostToSurfaceModule
// The 'ipc' channel is retained only for the disconnect signal below.
const channel = new FramedChannel(dataSocket, (msg) => ipcWrapper.receivedMessage(msg as any))

process.on('disconnect', () => process.exit())
// Safety net: if the parent dies/crashes without sending 'destroy', the IPC channel
// closes. Since we now ignore signals, this is the only thing that reaps us — attempt a
// best-effort surface reset, then exit. Hard-bounded so a hung destroy still exits.
let disconnecting = false
process.on('disconnect', () => {
if (disconnecting) return
disconnecting = true
const forceExit = setTimeout(() => process.exit(1), 2000)
forceExit.unref?.()
Promise.resolve()
.then(async () => {
if (plugin && pluginInitialized) await plugin.destroy()
})
.catch(() => {})
.finally(() => process.exit(1))
})

registerLoggingSink((source, level, message) => {
ipcWrapper.sendWithNoCb('log-message', {
Expand Down
107 changes: 107 additions & 0 deletions companion/test/Graphics/LayeredRenderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Canvas, GlobalFonts } from '@napi-rs/canvas'
import { beforeAll, describe, expect, test } from 'vitest'
import type { ElementGeometry } from '@companion-app/shared/Graphics/Geometry.js'
import { GraphicsLayeredButtonRenderer } from '@companion-app/shared/Graphics/LayeredRenderer.js'
import type { RendererButtonStyle } from '@companion-app/shared/Model/Render.js'
import type {
Expand Down Expand Up @@ -1768,4 +1769,110 @@ describe('GraphicsLayeredButtonRenderer', () => {
expect(cssTransparent.equals(numericTransparent)).toBe(true)
})
})

describe('element geometry', () => {
// A 72x58 image with 2px padding and no decoration leaves a 68x54 content area at (2, 2)
async function drawGeometry(elements: SomeButtonGraphicsDrawElement[]): Promise<ElementGeometry[]> {
const img = Image.create(72, 58, 1, null)
return GraphicsLayeredButtonRenderer.draw(
img,
makeStyle({ decoration: ButtonGraphicsDecorationType.None, elements }),
new Set(),
null,
DEFAULT_PADDING
)
}

function rectOf(entry: ElementGeometry) {
const { x, y, width, height } = entry.bounds
return { x, y, width, height }
}

test('resolves a top-level element against the content bounds', async () => {
const geometry = await drawGeometry([makeBoxElement({ id: 'a', x: 0.25, y: 0.5, width: 0.5, height: 0.25 })])

expect(geometry).toHaveLength(1)
expect(rectOf(geometry[0])).toEqual({ x: 2 + 17, y: 2 + 27, width: 34, height: 13.5 })
expect(geometry[0].rotations).toEqual([])
})

test('emits parents before their children, in draw order', async () => {
const geometry = await drawGeometry([
makeBoxElement({ id: 'under' }),
makeGroupElement([makeBoxElement({ id: 'child' })], { id: 'g' }),
])

expect(geometry.map((entry) => entry.id)).toEqual(['under', 'g', 'child'])
})

test('composes a child against its group, not the content bounds', async () => {
// Group occupies the right half; the child fills the left half of that
const geometry = await drawGeometry([
makeGroupElement([makeBoxElement({ id: 'child', width: 0.5 })], { id: 'g', x: 0.5, width: 0.5 }),
])

expect(rectOf(geometry[1])).toEqual({ x: 2 + 34, y: 2, width: 17, height: 54 })
})

test('squareCoords records the pre-square group bounds but gives children the square space', async () => {
const geometry = await drawGeometry([
makeGroupElement([makeBoxElement({ id: 'child' })], { id: 'g', squareCoords: true }),
])

expect(rectOf(geometry[0])).toEqual({ x: 2, y: 2, width: 68, height: 54 })
// The square is 54x54, centred horizontally within the 68-wide group
expect(rectOf(geometry[1])).toEqual({ x: 2 + 7, y: 2, width: 54, height: 54 })
})

test('a rotated element records a rotation about its own bounds', async () => {
const geometry = await drawGeometry([makeBoxElement({ id: 'a', rotation: 30 })])

expect(geometry[0].rotations).toEqual([{ pivot: geometry[0].bounds, angle: 30 }])
})

test('nested rotations accumulate outermost-first, pivoting about the square group space', async () => {
const geometry = await drawGeometry([
makeGroupElement([makeBoxElement({ id: 'child', rotation: 40 })], {
id: 'g',
rotation: 25,
squareCoords: true,
}),
])

const [groupEntry, childEntry] = geometry
// The group rotates about the square space it hands its children, not its own wider bounds
const groupPivot = groupEntry.rotations[0].pivot
expect({ x: groupPivot.x, y: groupPivot.y, width: groupPivot.width, height: groupPivot.height }).toEqual({
x: 2 + 7,
y: 2,
width: 54,
height: 54,
})

expect(childEntry.rotations).toEqual([
{ pivot: groupPivot, angle: 25 },
{ pivot: childEntry.bounds, angle: 40 },
])
})

test('hidden and disabled elements still report their geometry', async () => {
const img = Image.create(72, 58, 1, null)
const geometry = await GraphicsLayeredButtonRenderer.draw(
img,
makeStyle({
decoration: ButtonGraphicsDecorationType.None,
elements: [
makeBoxElement({ id: 'hidden' }),
makeBoxElement({ id: 'off', enabled: false }),
makeBoxElement({ id: 'shown' }),
],
}),
new Set(['hidden']),
null,
DEFAULT_PADDING
)

expect(geometry.map((entry) => entry.id)).toEqual(['hidden', 'off', 'shown'])
})
})
})
Loading
Loading