Skip to content

feat: gauge element #4141#4228

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

feat: gauge element #4141#4228
Julusian wants to merge 35 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:

Screenshot From 2026-06-18 20-41-51 Screenshot From 2026-06-18 20-42-00 Screenshot From 2026-06-18 20-42-13

Summary by CodeRabbit

Summary of changes

  • New Features
    • Added a new Gauge element type (horizontal, vertical, ring) with value/range mapping, configurable track styling, optional rounded ends, multi-color gradient stops, and marker support.
    • Added a Gauge option to the editor’s add-element popover.
  • Bug Fixes
    • Improved rendering consistency by refining opacity/alpha compositing for multiple element types.
    • Increased robustness of enum/boolean handling for safer defaults; made gauge/border dropdown choices schema-driven.
  • Tests
    • Added gauge rendering and conversion coverage, plus new arc stroke image drawing 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ec888f6f-88e6-4e0b-82ff-b8b9322d05a0

📥 Commits

Reviewing files that changed from the base of the PR and between 3c02db6 and e653e72.

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

📝 Walkthrough

Walkthrough

Adds a gauge graphics element type end-to-end: new TypeScript interfaces and element schema, default factory in button layer creation, element-to-draw conversion pipeline with schema-derived enum validation, canvas rendering for both ring (arc-based) and rectangular (horizontal/vertical) modes, a UI popover button, and a new arcStroke canvas primitive. Existing opacity compositing for text, box, circle, and image placeholder elements is migrated from usingAlpha to usingTemporaryLayer.

Changes

Gauge Element Graphics System

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
ButtonGraphicsGaugeElement and ButtonGraphicsGaugeDrawElement interfaces are defined and added to union types; gaugeElementSchema with value, appearance, fill, marker, and track sections is registered in elementSchemas and elementSimpleModeFields; internal:table field type is added to the graphics type generator.
Gauge UI button and creation defaults
webui/src/Buttons/EditButton/LayeredButtonEditor/Buttons.tsx, companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts
A "Gauge" popover button with faGauge icon is added to the element editor; CreateElementOfType gains a 'gauge' case that builds a default gauge element with layout, value/range, orientation, three default color stops, and inactive styling defaults.
Canvas graphics primitives for alpha and arc drawing
shared-lib/lib/Graphics/ImageBase.ts
LineStyle gains optional cap property for line-cap control; usingAlpha changes to compose alpha multiplicatively against current globalAlpha; new arcStroke method draws unfilled stroked arcs with configurable caps and early-return guards for zero radius or lineWidth.
Gauge conversion and schema-backed enum lookup
companion/lib/Graphics/ConvertGraphicsElements.ts, companion/lib/Graphics/ConvertGraphicsElements/Helper.ts
A dropdownChoices helper precomputes schema-derived choice ID arrays for gauge orientation/trackStyle and existing canvas/image/text/border fields, replacing hardcoded lists; convertGaugeElementForDrawing builds ButtonGraphicsGaugeDrawElement with value clamping to 0–100 in 0.1 steps and thickness clamped 1–50; helper functions are updated to handle empty/missing enum and boolean values gracefully.
Gauge rendering and opacity compositing migration
shared-lib/lib/Graphics/LayeredRenderer.ts
#drawGaugeElement renders ring gauges via sorted-threshold arcs with arcStroke and optional rounded end-cap circles, and rectangular gauges via solid/dimmed rectangles; transparent-mode ring compositing uses usingTemporaryLayer; text, box, circle, and image placeholder renderers migrate from usingAlpha to usingTemporaryLayer.
Gauge conversion, rendering, and arcStroke tests
companion/test/Graphics/ConvertGraphicsElements.test.ts, companion/test/Graphics/LayeredRenderer.test.ts, companion/test/Graphics/Image.test.ts, companion/test/Graphics/ConvertGraphicsElements/Helper.test.ts
Pixel-level and snapshot tests cover arcStroke behavior; conversion tests cover value clamping, enum tolerant parsing, segment behavior, opacity scaling, and contentHash stability; renderer snapshot tests cover all gauge orientations, segment modes, inactive styles, ring geometry, and edge cases; helper tests verify graceful undefined property handling.

Poem

A gauge now graces every button's face,
With rings and bars to fill the allocated space 🎚️
Thresholds sorted, arcs stroked butt-cap clean,
The smoothest color gradients ever seen ✨
From schema born to canvas finally drawn—
Welcome, little gauge, from dusk until dawn! 🌅

🚥 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 identifies the main addition: a new gauge element feature, with issue reference.
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 thread companion/lib/Graphics/ConvertGraphicsElements.ts Outdated
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 Outdated
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.

@Julusian

Copy link
Copy Markdown
Member Author

ok, I've done another pass of this, and expanded out the properties quite a bit (see description for new screenshots)
which I think covers everything discussed here that I think is a good idea to do at this stage

I've renamed various properties, so any existing test configs wont translate across cleanly

@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: 1

Caution

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

⚠️ Outside diff range comments (1)
shared-lib/lib/Graphics/ImageBase.ts (1)

233-242: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Prevent lineCap state from leaking across subsequent draw calls.

At Line 236 and Line 411, lineCap is written onto the shared canvas context but never restored. If a rounded marker sets 'round', later stroke operations that don’t set lineCap (for example open arc/path strokes) can render with unintended caps depending on draw order.

Suggested fix
 line(x1: number, y1: number, x2: number, y2: number, style: LineStyle): void {
+		const oldLineCap = this.context2d.lineCap
 		this.context2d.lineWidth = style.width ?? 1
 		this.context2d.strokeStyle = style.color
 		this.context2d.lineCap = style.cap ?? 'butt'
-		this.context2d.beginPath()
-		this.context2d.moveTo(x1, y1)
-		this.context2d.lineTo(x2, y2)
-		this.context2d.closePath()
-		this.context2d.stroke()
+		try {
+			this.context2d.beginPath()
+			this.context2d.moveTo(x1, y1)
+			this.context2d.lineTo(x2, y2)
+			this.context2d.closePath()
+			this.context2d.stroke()
+		} finally {
+			this.context2d.lineCap = oldLineCap
+		}
 	}
@@
 	arcStroke(
 		x: number,
 		y: number,
 		radius: number,
@@
 	): void {
 		if (radius <= 0 || lineStyle.width <= 0) return
+		const oldLineCap = this.context2d.lineCap
 		this.context2d.beginPath()
 		this.context2d.arc(x, y, radius, startAngle, endAngle, anticlockwise)
 		this.context2d.strokeStyle = lineStyle.color
 		this.context2d.lineWidth = lineStyle.width
 		this.context2d.lineCap = lineStyle.cap ?? 'butt'
-		this.context2d.stroke()
+		try {
+			this.context2d.stroke()
+		} finally {
+			this.context2d.lineCap = oldLineCap
+		}
 	}

Also applies to: 397-413

🧹 Nitpick comments (1)
shared-lib/lib/Model/StyleLayersModel.ts (1)

231-231: ⚡ Quick win

Use a dedicated stop-row type for stronger gauge contracts.

stops is currently modeled as Record<string, JsonValue>[], but downstream code expects a fixed { value, color, gradient } shape. Tightening the draw-model type would improve safety and catch drift earlier.

Also applies to: 256-256


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 039bec80-c5e4-4efd-af1a-5fa2ce9b77f7

📥 Commits

Reviewing files that changed from the base of the PR and between 59a05fa and 1726d84.

📒 Files selected for processing (7)
  • companion/lib/Controls/ControlTypes/Button/LayerDefaults.ts
  • companion/lib/Graphics/ConvertGraphicsElements.ts
  • companion/lib/Graphics/ConvertGraphicsElements/Helper.ts
  • shared-lib/lib/Graphics/ElementPropertiesSchemas.ts
  • shared-lib/lib/Graphics/ImageBase.ts
  • shared-lib/lib/Graphics/LayeredRenderer.ts
  • shared-lib/lib/Model/StyleLayersModel.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • shared-lib/lib/Graphics/LayeredRenderer.ts

Comment thread shared-lib/lib/Graphics/ElementPropertiesSchemas.ts Outdated
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