Skip to content

feat: gauge element #4141#4228

Open
Julusian wants to merge 30 commits into
mainfrom
feat/gauge-element
Open

feat: gauge element #4141#4228
Julusian wants to merge 30 commits into
mainfrom
feat/gauge-element

Conversation

@Julusian

@Julusian Julusian commented Jun 6, 2026

Copy link
Copy Markdown
Member

Just another element.

Screencast.From.2026-06-06.21-19-29.mp4

current properties (the thresholds section needs polish):
image

Summary by CodeRabbit

  • New Features

    • Added a Gauge element for button graphics (0–100 value) with horizontal, vertical and ring layouts, configurable color segments, inactive-portion styling, thickness, rounded ends, and multi-segment behavior.
  • Improvements

    • Improved opacity and compositing behavior for image, text and placeholder rendering.
    • Added finer value clamping/rounding and schema-driven option handling for various graphics controls.
  • Tests

    • Added comprehensive gauge conversion/rendering tests and new arc stroke image tests.

@Julusian Julusian added this to the v5.0 milestone Jun 6, 2026
@github-project-automation github-project-automation Bot moved this to In Progress in Companion Plan Jun 6, 2026
@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This pull request introduces gauge graphics elements to Companion's button layer system. The changes define gauge models, register configuration schemas, implement conversion and rendering pipelines, update graphics primitives for alpha composition and arc drawing, and add the gauge creation button to the editor UI with matching test coverage.

Changes

Gauge Graphics Element Support

Layer / File(s) Summary
Gauge data models and element schemas
shared-lib/lib/Model/StyleLayersModel.ts, shared-lib/lib/Graphics/ElementPropertiesSchemas.ts, tools/generate_graphics_types.mts
Adds ButtonGraphicsGaugeElement and ButtonGraphicsGaugeDrawElement interfaces with orientation, value, segments, and inactive styling properties. Registers gaugeElementSchema with UI configuration sections for appearance, coloring, and inactive rendering. Adds internal table type support in generated graphics typing.
Gauge UI button and creation defaults
webui/src/Buttons/EditButton/LayeredButtonEditor/Buttons.tsx, companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts
Adds "Gauge" button with faGauge icon to the add-element popover. Implements default gauge object creation with layout, color segments, and inactive styling defaults.
Expression helper utilities
companion/lib/Graphics/ConvertGraphicsElements/Helper.ts
Adds getTolerantEnum for case-insensitive prefix-based enum matching and forRow to normalize object rows into expression-capable records.
Gauge conversion and schema-backed enum lookup
companion/lib/Graphics/ConvertGraphicsElements.ts
Implements convertGaugeElementForDrawing to evaluate gauge properties, round values to 0.1 increments, clamp thickness, and map segment rows. Adds schema-derived dropdown choice lookup for canvas decoration, image fill mode, text font, border position, and gauge orientation/inactive style instead of hardcoded enums.
Canvas graphics primitives
shared-lib/lib/Graphics/ImageBase.ts
Updates usingAlpha to compose by multiplying existing globalAlpha instead of replacing it. Adds arcStroke method to draw unfilled stroked arcs with optional anticlockwise rendering.
Gauge rendering and opacity composition
shared-lib/lib/Graphics/LayeredRenderer.ts
Implements #drawGaugeElement supporting ring arcs (with active/inactive threshold-based coloring, optional rounded end-caps, and transparent-mode compositing) and rectangular segments (solid active, dimmed inactive). Updates text, box, circle, and image placeholder rendering to use usingTemporaryLayer for opacity composition.
Tests
companion/test/Graphics/Image.test.ts, companion/test/Graphics/ConvertGraphicsElements.test.ts, companion/test/Graphics/LayeredRenderer.test.ts
Adds arcStroke pixel and snapshot tests validating arc rendering and edge cases. Implements gauge conversion test suite asserting value normalization, enum tolerant parsing, thickness/segment clamping, and opacity scaling. Adds gauge renderer snapshot tests covering orientations, value ranges, inactive styles, segment configurations, and ring geometry options.

📊 A gauge is born with colors bright,
Arcs and rings in left and right,
From schema's truth to canvas drawn,
Composing layers till it's done! ✨

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: gauge element #4141' clearly and concisely describes the main change—adding a new gauge element type. It's specific, directly related to the changeset, and follows conventional commit format.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 54a0a783-d01a-445f-a7de-a7045a799b8d

📥 Commits

Reviewing files that changed from the base of the PR and between ced2fcb and ae7f7a9.

⛔ Files ignored due to path filters (30)
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_empty_thresholds_-_nothing_drawn.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_inactiveAmount_0_-_inactive_portions_invisible.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_inactiveAmount_100_-_inactive_same_as_active_colour.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_inactiveStyle_dimmed_-_inactive_portions_darkened.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_multiSegment_false_-_single_colour_for_entire_active_region.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_orientation_vertical_reverse_false_-_fills_from_bottom.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_orientation_vertical_reverse_true_-_fills_from_top.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_reverse_true_-_fills_from_right.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_in_non-square_element_-_stays_circular.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_multiSegment_false_value_75_-_single_colour_active.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_reverse_true_value_75_-_counter-clockwise.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_roundedEnds_false_value_75_-_flat_ends.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_thick_thickness_40.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_thin_thickness_8.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_0_-_inactive_arc_only_dark_bg_makes_it_visible.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_100_-_fully_active.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_33_-_one_colour_within_first_segment.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_50_-_midway_through_first_segment.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_66_-_exactly_at_first_threshold_boundary.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_75_-_crossing_into_yellow_segment.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_75_dimmed_inactive_-_both_halves_clearly_visible.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_90_-_crossing_into_red_segment.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_single_threshold_-_full_bar_one_colour.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_unsorted_thresholds_-_sorted_before_rendering.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_0_-_only_inactive_background_visible.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_100_-_all_segments_active_no_inactive.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_50_-_first_segment_partially_active.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_75_-_two_segments_active_green_yellow_red_inactive.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/Image_drawing_arcStroke_snapshot_-_full_circle_and_quarter_arcs.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/Image_drawing_arcStroke_snapshot_-_thick_vs_thin_arc.png is excluded by !**/*.png
📒 Files selected for processing (17)
  • companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts
  • companion/lib/Graphics/ConvertGraphicsElements.ts
  • companion/lib/Graphics/ConvertGraphicsElements/Helper.ts
  • companion/test/Graphics/Image.test.ts
  • companion/test/Graphics/LayeredRenderer.test.ts
  • shared-lib/lib/Graphics/ElementPropertiesSchemas.ts
  • shared-lib/lib/Graphics/ImageBase.ts
  • shared-lib/lib/Graphics/LayeredRenderer.ts
  • shared-lib/lib/Model/Options.ts
  • shared-lib/lib/Model/StyleLayersModel.ts
  • shared-lib/lib/ValidateInputValue.ts
  • shared-lib/lib/__tests__/validate-input-value.test.ts
  • tools/generate_graphics_types.mts
  • webui/src/Buttons/EditButton/LayeredButtonEditor/Buttons.tsx
  • webui/src/Components/TableInputField.stories.tsx
  • webui/src/Components/TableInputField.tsx
  • webui/src/Controls/OptionsInputField.tsx

Comment on lines +802 to +807
const thresholdsRaw = (element.thresholds as ExpressionOrValue<JsonValue[]>).value
const thresholds: ButtonGraphicsGaugeDrawElement['thresholds'] = Array.isArray(thresholdsRaw)
? thresholdsRaw.map((row) => ({
value: Math.max(0, Math.min(100, Number((row as any)?.value ?? 0))),
color: Number((row as any)?.color ?? 0),
}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Harden gauge numeric sanitization before rendering.

Number(...) can yield NaN for malformed threshold rows, and inactiveAmount is currently unbounded. Clamping to finite 0–100 inputs here avoids invalid color/segment math downstream.

💡 Suggested fix
+	const toFiniteNumber = (input: unknown, fallback: number): number => {
+		const n = Number(input)
+		return Number.isFinite(n) ? n : fallback
+	}
+
 	const thresholdsRaw = (element.thresholds as ExpressionOrValue<JsonValue[]>).value
 	const thresholds: ButtonGraphicsGaugeDrawElement['thresholds'] = Array.isArray(thresholdsRaw)
 		? thresholdsRaw.map((row) => ({
-				value: Math.max(0, Math.min(100, Number((row as any)?.value ?? 0))),
-				color: Number((row as any)?.color ?? 0),
+				value: Math.max(0, Math.min(100, toFiniteNumber((row as any)?.value, 0))),
+				color: toFiniteNumber((row as any)?.color, 0),
 			}))
 		: []
@@
-		inactiveAmount: helper.getNumber('inactiveAmount', 70),
+		inactiveAmount: Math.max(0, Math.min(100, helper.getNumber('inactiveAmount', 70))),

Also applies to: 826-826

Comment thread companion/lib/Graphics/ConvertGraphicsElements/Helper.ts Outdated
Comment thread shared-lib/lib/Graphics/LayeredRenderer.ts Outdated
Comment thread shared-lib/lib/Graphics/LayeredRenderer.ts Outdated
@Julusian

Julusian commented Jun 6, 2026

Copy link
Copy Markdown
Member Author

@thedist I feel you may have some input on the design/functionality here.
I haven't verified it can do everything that your util can, but I did ask claude to use it as a reference for the initial sketch. I still need to fully read all the code too

@Julusian Julusian moved this to In Progress in Graphics generation overhaul Jun 6, 2026
@Julusian Julusian force-pushed the feat/gauge-element branch from 0a0cb1c to 10587b3 Compare June 7, 2026 13:41

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
webui/src/Components/ListInputField.tsx (1)

113-142: ⚡ Quick win

Consider using functional updates for setValue to prevent stale closure reads.

The current callbacks (addRow, removeRow, moveRow, updateCell) close over rows and call setValue([...rows, ...]). If setValue is triggered multiple times before re-render completes (e.g., rapid user clicks), both calls might read the same stale rows value, potentially losing updates.

Using functional updates makes the callbacks more stable and prevents this edge case:

♻️ Suggested refactor using functional updates
 const addRow = useCallback(() => {
-  setValue([...rows, newRow(definition.fields)])
-}, [rows, definition.fields, setValue])
+  setValue((prevRows) => [...prevRows, newRow(definition.fields)])
+}, [definition.fields, setValue])

 const removeRow = useCallback(
   (rowIndex: number) => {
-    setValue(rows.filter((_, i) => i !== rowIndex))
+    setValue((prevRows) => prevRows.filter((_, i) => i !== rowIndex))
   },
-  [rows, setValue]
+  [setValue]
 )

 const moveRow = useCallback(
   (rowIndex: number, direction: -1 | 1) => {
+    setValue((prevRows) => {
+      const rows = prevRows
       const next = rowIndex + direction
       if (next < 0 || next >= rows.length) return
       const updated = [...rows]
       ;[updated[rowIndex], updated[next]] = [updated[next], updated[rowIndex]]
-      setValue(updated)
+      return updated
+    })
   },
-  [rows, setValue]
+  [setValue]
 )

 const updateCell = useCallback(
   (rowIndex: number, fieldId: string, cellValue: ExpressionOrValue<JsonValue | undefined>) => {
-    setValue(
-      rows.map((row, i) => (i === rowIndex ? { ...row, [fieldId]: cellValue as ExpressionOrValue<JsonValue> } : row))
-    )
+    setValue((prevRows) =>
+      prevRows.map((row, i) => (i === rowIndex ? { ...row, [fieldId]: cellValue as ExpressionOrValue<JsonValue> } : row))
+    )
   },
-  [rows, setValue]
+  [setValue]
 )

This also makes the callbacks more stable (recreated less often) since they no longer depend on rows.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1c5e00cd-7c97-43af-9cc8-9a67602cafc2

📥 Commits

Reviewing files that changed from the base of the PR and between 8ae4a4d and 10587b3.

⛔ Files ignored due to path filters (32)
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_box_properties_box_with_opacity_-_semi-transparent_over_another_element.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_empty_thresholds_-_nothing_drawn.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_inactiveAmount_0_-_inactive_portions_invisible.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_inactiveAmount_100_-_inactive_same_as_active_colour.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_inactiveStyle_dimmed_-_inactive_portions_darkened.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_multiSegment_false_-_single_colour_for_entire_active_region.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_orientation_vertical_reverse_false_-_fills_from_bottom.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_orientation_vertical_reverse_true_-_fills_from_top.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_reverse_true_-_fills_from_right.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_in_non-square_element_-_stays_circular.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_multiSegment_false_value_75_-_single_colour_active.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_reverse_true_value_75_-_counter-clockwise.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_roundedEnds_false_value_75_-_flat_ends.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_thick_thickness_40.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_thin_thickness_8.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_0_-_inactive_arc_only_dark_bg_makes_it_visible.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_100_-_fully_active.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_33_-_one_colour_within_first_segment.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_50_-_midway_through_first_segment.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_66_-_exactly_at_first_threshold_boundary.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_75_-_crossing_into_yellow_segment.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_75_dimmed_inactive_-_both_halves_clearly_visible.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_90_-_crossing_into_red_segment.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_single_threshold_-_full_bar_one_colour.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_unsorted_thresholds_-_sorted_before_rendering.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_0_-_only_inactive_background_visible.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_100_-_all_segments_active_no_inactive.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_50_-_first_segment_partially_active.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_value_75_-_two_segments_active_green_yellow_red_inactive.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_group_properties_group_with_rotation.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/Image_drawing_arcStroke_snapshot_-_full_circle_and_quarter_arcs.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/Image_drawing_arcStroke_snapshot_-_thick_vs_thin_arc.png is excluded by !**/*.png
📒 Files selected for processing (23)
  • companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts
  • companion/lib/Graphics/ConvertGraphicsElements.ts
  • companion/lib/Graphics/ConvertGraphicsElements/Helper.ts
  • companion/test/Graphics/ConvertGraphicsElements.test.ts
  • companion/test/Graphics/Image.test.ts
  • companion/test/Graphics/LayeredRenderer.test.ts
  • shared-lib/lib/Graphics/ElementPropertiesSchemas.ts
  • shared-lib/lib/Graphics/ImageBase.ts
  • shared-lib/lib/Graphics/LayeredRenderer.ts
  • shared-lib/lib/Model/Options.ts
  • shared-lib/lib/Model/StyleLayersModel.ts
  • shared-lib/lib/ValidateInputValue.ts
  • shared-lib/lib/__tests__/validate-input-value.test.ts
  • tools/generate_graphics_types.mts
  • webui/src/Buttons/EditButton/LayeredButtonEditor/Buttons.tsx
  • webui/src/Buttons/EditButton/LayeredButtonEditor/ElementPropertiesEditor.tsx
  • webui/src/Components/ListInputField.stories.tsx
  • webui/src/Components/ListInputField.tsx
  • webui/src/Components/TableInputField.stories.tsx
  • webui/src/Components/TableInputField.tsx
  • webui/src/Components/__tests__/ListInputField.test.tsx
  • webui/src/Components/__tests__/TableInputField.test.tsx
  • webui/src/Controls/OptionsInputField.tsx
🚧 Files skipped from review as they are similar to previous changes (13)
  • webui/src/Buttons/EditButton/LayeredButtonEditor/Buttons.tsx
  • tools/generate_graphics_types.mts
  • companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts
  • companion/lib/Graphics/ConvertGraphicsElements/Helper.ts
  • shared-lib/lib/Model/StyleLayersModel.ts
  • companion/test/Graphics/Image.test.ts
  • shared-lib/lib/Graphics/ImageBase.ts
  • webui/src/Components/TableInputField.tsx
  • webui/src/Components/TableInputField.stories.tsx
  • companion/lib/Graphics/ConvertGraphicsElements.ts
  • shared-lib/lib/Model/Options.ts
  • shared-lib/lib/Graphics/LayeredRenderer.ts
  • shared-lib/lib/ValidateInputValue.ts

Comment thread companion/test/Graphics/ConvertGraphicsElements.test.ts
Comment thread companion/test/Graphics/ConvertGraphicsElements.test.ts Outdated
Comment thread companion/test/Graphics/LayeredRenderer.test.ts Outdated
Comment thread companion/test/Graphics/LayeredRenderer.test.ts
@thedist

thedist commented Jun 7, 2026

Copy link
Copy Markdown
Member

What's the best way to give this a test? I was going to check it out with the Voicemeeter module as that's a good one to test the performance of lots of meters (and with the imagebuffers not working with the 5.0 graphics system is one of the main modules I need ready for 5.0's release) but was expecting the 'Guage' element to be similar to the group/text/image/line/circle/etc... elements but it's not showing in that list. so I'm wondering if it's an element somewhere else and I'm misunderstanding where it's located?

@Julusian

Julusian commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

yeah this is just another element type:
image

only in this branch. not available in presets yet either

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
webui/src/Controls/OptionsInputField.tsx (1)

150-150: 💤 Low value

Minor: Consider using the imported constant for consistency.

You're using an inline object { variables: true, local: true } here, but InputFeatures.tsx exports ExpressionModeFeatures with the same values. Using the constant would be more maintainable if these values ever need to change together.

💡 Optional consistency improvement
+import { ExpressionModeFeatures, getInputFeatures, InputFeatureIcons, type InputFeatureIconsProps } from './InputFeatures.js'
-import { getInputFeatures, InputFeatureIcons, type InputFeatureIconsProps } from './InputFeatures.js'
...
-				<OptionLabel option={option} features={isInExpressionMode ? { variables: true, local: true } : features} />
+				<OptionLabel option={option} features={isInExpressionMode ? ExpressionModeFeatures : features} />
webui/src/Components/PropertyFieldRow.tsx (1)

79-80: Document/guard expression semantics when disableAutoExpression is true

In PropertyFieldRow, the disableAutoExpression branch bypasses FieldOrExpression and passes children({ value: value.value }). Since ExpressionOrValue<T> allows { isExpression: true; value: string }, any existing expression payload would be treated as a literal JSON string, and subsequent edits via setInnerValue will force { isExpression: false }. Add a short invariant/comment (or upstream normalization) clarifying that this is intentional (i.e., disableAutoExpression implies value.isExpression === false, or that preserving the literal string is desired).


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: feeca19d-1446-4b1b-83c3-ad0a84ecb3eb

📥 Commits

Reviewing files that changed from the base of the PR and between 10587b3 and c082f90.

⛔ Files ignored due to path filters (4)
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_empty_segments_-_nothing_drawn.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_ring_value_66_-_exactly_at_first_segment_boundary.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_single_segment_-_full_bar_one_colour.png is excluded by !**/*.png
  • companion/test/Graphics/__snapshots__/GraphicsLayeredButtonRenderer_gauge_element_unsorted_segments_-_sorted_before_rendering.png is excluded by !**/*.png
📒 Files selected for processing (15)
  • companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts
  • companion/lib/Graphics/ConvertGraphicsElements.ts
  • companion/test/Graphics/ConvertGraphicsElements.test.ts
  • companion/test/Graphics/LayeredRenderer.test.ts
  • shared-lib/lib/ValidateInputValue.ts
  • webui/src/Buttons/EditButton/LayeredButtonEditor/ControlOptionsEditor.tsx
  • webui/src/Buttons/EditButton/LayeredButtonEditor/ElementPropertiesEditor.tsx
  • webui/src/Buttons/EditButton/LayeredButtonEditor/ElementPropertiesUtil.tsx
  • webui/src/Components/ListInputField.tsx
  • webui/src/Components/PropertyFieldRow.tsx
  • webui/src/Components/TableInputField.tsx
  • webui/src/Controls/Components/AddElementPickerModal.tsx
  • webui/src/Controls/InputFeatures.tsx
  • webui/src/Controls/OptionsInputField.tsx
  • webui/src/Surfaces/EditPanelConfigField.tsx
✅ Files skipped from review due to trivial changes (2)
  • webui/src/Surfaces/EditPanelConfigField.tsx
  • webui/src/Buttons/EditButton/LayeredButtonEditor/ControlOptionsEditor.tsx
🚧 Files skipped from review as they are similar to previous changes (7)
  • companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts
  • webui/src/Components/TableInputField.tsx
  • webui/src/Buttons/EditButton/LayeredButtonEditor/ElementPropertiesEditor.tsx
  • shared-lib/lib/ValidateInputValue.ts
  • webui/src/Components/ListInputField.tsx
  • companion/test/Graphics/LayeredRenderer.test.ts
  • companion/lib/Graphics/ConvertGraphicsElements.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bc3d289a-75e8-4642-aaf0-7af34af4571d

📥 Commits

Reviewing files that changed from the base of the PR and between c082f90 and 03b17b1.

📒 Files selected for processing (3)
  • companion/lib/Graphics/ConvertGraphicsElements.ts
  • companion/lib/Graphics/ConvertGraphicsElements/Helper.ts
  • shared-lib/lib/Graphics/ElementPropertiesSchemas.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • shared-lib/lib/Graphics/ElementPropertiesSchemas.ts

Comment thread companion/lib/Graphics/ConvertGraphicsElements.ts
Comment thread companion/lib/Graphics/ConvertGraphicsElements/Helper.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
companion/lib/Graphics/ConvertGraphicsElements/Helper.ts (1)

78-81: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical: #getValue returns undefined for missing properties, causing crashes in partial row data.

This issue was raised in a previous review but remains unaddressed. When forRow normalizes an incomplete row (e.g., { color: 0xff0000 } missing the value key), #getValue('value') returns undefined instead of an ExpressionOrValue object. Subsequent calls like getNumber('value', 0) then access undefined.isExpression, throwing a TypeError.

From the gauge converter (context snippet 2):

const rowHelper = helper.forRow(row)
return {
    value: rowHelper.getNumber('value', 0),  // crashes if row missing 'value'
    color: rowHelper.getNumber('color', 0),
}

This is a runtime regression from previous behavior that tolerated missing table cells by defaulting them.

🐛 Required fix
 `#getValue`(propertyName: keyof T): ExpressionOrValue<JsonValue | undefined> {
 	const override = this.#elementOverrides?.get(String(propertyName))
-	return override ? override : (this.#element as any)[propertyName]
+	return override ?? (this.#element as any)[propertyName] ?? { isExpression: false, value: undefined }
 }

This ensures #getValue always returns a valid ExpressionOrValue object, even when the property doesn't exist, maintaining backward compatibility with the previous tolerant parsing behavior.

🧹 Nitpick comments (1)
companion/lib/Graphics/ConvertGraphicsElements/Helper.ts (1)

164-170: ⚡ Quick win

Consider guarding against empty trimmed string.

If raw is null, undefined, or an empty string, trimmed becomes '' and trimmed[0] is undefined. While the current code handles this (JavaScript converts undefined to "undefined" in startsWith, no match occurs, and defaultValue is returned), it relies on type coercion and performs an unnecessary string comparison.

For clarity and efficiency, consider adding an early return:

Suggested improvement
 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()
+	if (!trimmed) return defaultValue
 	return values.find((v) => v.toLowerCase().startsWith(trimmed[0])) ?? defaultValue
 }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 29a57ac4-d2aa-4f22-a1d1-4fa203cc37a2

📥 Commits

Reviewing files that changed from the base of the PR and between c082f90 and 41b0d94.

📒 Files selected for processing (3)
  • companion/lib/Graphics/ConvertGraphicsElements.ts
  • companion/lib/Graphics/ConvertGraphicsElements/Helper.ts
  • shared-lib/lib/Graphics/ElementPropertiesSchemas.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • shared-lib/lib/Graphics/ElementPropertiesSchemas.ts
  • companion/lib/Graphics/ConvertGraphicsElements.ts

@dnmeid

dnmeid commented Jun 7, 2026

Copy link
Copy Markdown
Member

Hey, I don't have time for testing it currently, but here are my thoughts:

  • the gauge needs a way of positioning and sizing, like a bounding box
  • instead of horizontal, vertical and ring together with reverse direction, there should be "linear" and "circular" with a rotation parameter in degrees. Maybe we'll find more styles in the future.
  • Why make a multisegemet checkbox? Do we expect demand to switch on and off segments programatically? I think it should be enough to just have a variable amount of segments with a minimum of one. By default we start with one segment and users can add more if they want.
  • An alternative idea to the exposure of segments is the exposure of "stops". With stops you'd have a minimum of two, one at the minimal end an one at the maximal end. Each stop could have a parameter if the following segment will be the stop color or if it is a gradient to the next stop's color.
  • I think the gauge should be quite flexible so it has to work not only for stuff like volume meters, but also pan, EQ-settings, width... (wherever there is ONE parameter to visualize). I'd say the segment colors are colors (with alpha) of the segments when there is no indicator "on", i.e. the bare track. There should be a choice of the indicator type like "bar" (indicator grows from 0 to the value), "dot" (dot positions at the value), "line" (line positions at the value), "centerbar" (bar starts in the center and grows towards start and end, for width). Maybe a user defined image?
  • There should be individual sizing parameters for the track width and indicator width. For a circular gauge the track width would be the current "ring thickness"
  • The indicator should be stylable at least by a color with alpha and ideally with a draw mode, so you can e.g. saturate the background or invert it or just overlay your color
  • At least the circular type needs a way to make it not go full 360°. You could simply add a transparent segment for the top 20% but then your value range would only be from 0 to 80. Again no problem to solve with the expression, but the whole gauge is a convenience element. Everything could be done by animating basic elements too. So maybe we should add a minimum and maximum value for that convenience. A lot of audio related stuff has levels starting from some very low negative value to a small positive value, e.g. -232 to +24. With such parameters users don't have to do the math, they can just feed any value range. The circular gap could be solved by setting up a min of 0 and a max of 100, a first visible segment 0-100 and then a transparent segment of 100-120. So each segment starts at the end of the previous segment (the first segments starts at min value) and the number is the end of the segment. With stops it would also be possible to set the first stop below the minimum: 1. stop transparent at -10, 2. stop visible at 0, 3. stop transparent at 100, 4. stop n/a at 110. I'm quite confident that the min and max values are a good thing, but I'm not sure if it is too complicated to use an extra segment/stop for a gap in the circular gauge or if we should just spend it a dedicated parameter.
  • A lot of real world meters have adjustable attack and falloff times, i.e. an adjustable time how fast the visualization can increase and decrease. Especially for audio levels often you want a slower falloff so don't miss very short peaks. Should we have this? If so, with just two parameters or with some kind of adjustable temporal dampening function?
  • At the moment the gauge element like all other elements is only visualization, there is no interaction. We should be prepared that in the future people will want to put stuff like this on a screen or touchscreen and that they'll want to interact with it. Nothing to solve today, but something that should not be made impossible by some of today's decisions.

@Julusian

Julusian commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

the gauge needs a way of positioning and sizing, like a bounding box

There is the usual size position and rotation. (and enabled and opacity), I just left them out of the screenshot.

instead of horizontal, vertical and ring together with reverse direction, there should be "linear" and "circular" with a rotation parameter in degrees.

I think that will be challenging; which is a general problem with the current rotation. Because the width+height are a percentage, in a non-square button things get a little odd.
But yes, we could not have a reverse option and use rotation except for the ring mode that would still want it probably.

Why make a multisegemet checkbox?

That controls the colour of the value. Whether it shows each colour or gets filled with a single colour.
(Compare 2/1/5 and 2/2/5 in the video)

I think the gauge should be quite flexible so it has to work not only for stuff like volume meters, but also pan, EQ-settings, width...

The pan part of this makes sense, I'm not really sure what the others are asking for.
Something to keep in mind is that we don't need to implement every possible mode right now, as long as we havent designed it with bad decisions

There should be individual sizing parameters for the track width and indicator width. For a circular gauge the track width would be the current "ring thickness"

No real objection here, just that reusing the ring thickness for a veritcal/horisontal something will vastly change the behaviour of the drawing relative to the bounding box, or be something the user is forced to change when switching between the styles.
I am worried about it adding a second way to define the size that will add confusion too

At least the circular type needs a way to make it not go full 360°.

No objection to that.

maybe this is really asking for a start/end angle field. This would have overlap with rotation, but that might be the best way to allow this dead range

I'm quite confident that the min and max values are a good thing, but I'm not sure if it is too complicated to use an extra segment/stop for a gap in the circular gauge or if we should just spend it a dedicated parameter.

I'm ok with a min and max value fields, with the collapsing sections having a wall of properties doesnt bother me too much.
I think this ties in quite a bit with the starting from a midpoint (pan) too

Again no problem to solve with the expression, but the whole gauge is a convenience element. Everything could be done by animating basic elements too.

Yes and no. It can be done with basic elements, but not at all simple to do. Not too bad for the single colour versions, but if multiple colours then thats an element per colour

A lot of real world meters have adjustable attack and falloff times, i.e. an adjustable time how fast the visualization can increase and decrease. Especially for audio levels often you want a slower falloff so don't miss very short peaks. Should we have this?

For sure future scope. I dont see this as necessary for a first iteration.
Besides, we will only process the values as fast as the rendering system allows drawing to occur, so chances are even with this we would miss short peaks

@thedist

thedist commented Jun 9, 2026

Copy link
Copy Markdown
Member

I did some testing after some annoying git issues (solved by deleting the branch locally and running the same pull command again lol).

So far my main issues are lack of presets as it is a fair amount of work to get them up and running so I definitely expect module presets to be needed (I know there are some people who use modules JUST for the meters feedback they have that lets users put in variables so they use those meters with sources unrelated to the module, perhaps we should consider some generic presets built in to Companion with some pre-made meters to smooth this transition and provide better UX than needing to use separate modules or hand make meters).

Secondly, we need to consider what's the best user experience for handling logarithmic values. Should this be handled entirely by the user (in which case modules may need to do more to add appropriate variables), which means we would need more Expression Functions as I don't think there's currently an equivalent of Math.pow, or should there be some sort of 'logarithmic' option for the gauges?

As an example, vMix give audio metre data as a logarithmic amplitude from 0 to 1. This isn't much value to end users (at least it wasn't so far) so I don't expose that as a variable, but instead turn it in to a dB value. Such as an amplitude of 0.07890493, is roughly -22 dB, which is ~50 on a 0 to 100 linear gauge. Without a Math.pow function users can't get from either vMix's Amplitude, or the dB I provide, to that linear value... and that's even if they are a ware of the formula.

So personally I think modules should do a lot of the leg work where they can to straight up give users a 0 to 100 value variable that users can paste right into a gauge, but we're also going to need any missing Math functions that are going to be needed so users can do it themselves for modules that don't update.

At least the circular type needs a way to make it not go full 360°

I agree here. For a lot of circular gauges it's quite common graphically to have a gap in the gauge at the bottom for text, either a label or the textual value that's controlling the gauge.

A lot of real world meters have adjustable attack and falloff times

I can see this as being tough tom implement, as so many different modules handle things differently, like vMix returns the current amplitude of the moment of the request, where as Voicemeeter returns a value that already takes in to account fading so if there was a loud sound a second ago and then silence the returned value would actually be fading from that loud sound and not 0. Add in to this things like update frequency (I generally limit my modules with audio metres to 10 fps and any faster is problematic) and there's already some margin of error with how accurate the gauges would be compared to a 'real' audio metre device.

@peternewman

peternewman commented Jun 10, 2026

Copy link
Copy Markdown
Contributor
  • A lot of real world meters have adjustable attack and falloff times, i.e. an adjustable time how fast the visualization can increase and decrease. Especially for audio levels often you want a slower falloff so don't miss very short peaks. Should we have this? If so, with just two parameters or with some kind of adjustable temporal dampening function?

Could you do something similar by overlapping two meters, or more for the metering where it shows a peak level as well as the current level.

maybe this is really asking for a start/end angle field. This would have overlap with rotation, but that might be the best way to allow this dead range

I assume for a linear meter, you'd use normal sizing to potentially leave space above/below it?

Secondly, we need to consider what's the best user experience for handling logarithmic values. Should this be handled entirely by the user (in which case modules may need to do more to add appropriate variables), which means we would need more Expression Functions as I don't think there's currently an equivalent of Math.pow, or should there be some sort of 'logarithmic' option for the gauges?

At a bare minimum, the colour thresholds should accept expressions, as then you can use the same expression throughout all value fields (e.g. log(-18) or whatever) rather than having to pre-calculate those, but being able to use a function for the main values.

At least the circular type needs a way to make it not go full 360°

I agree here. For a lot of circular gauges it's quite common graphically to have a gap in the gauge at the bottom for text, either a label or the textual value that's controlling the gauge.

Consider also a circular audio pan meter might have the gap at the bottom, but e.g. a camera tilt display or stopwatch could have the gap at the side/top.

A lot of real world meters have adjustable attack and falloff times

I can see this as being tough tom implement, as so many different modules handle things differently, like vMix returns the current amplitude of the moment of the request, where as Voicemeeter returns a value that already takes in to account fading

Do you think anything returns both values already, current and averaged? Actually at least some Shure radio mics return both peak and RMS values.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress
Status: In Progress

Development

Successfully merging this pull request may close these issues.

4 participants