diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..b6f2440
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,47 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ - master
+ - develop
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ native-windows:
+ name: Native Windows Build And Tests
+ runs-on: windows-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Configure Debug
+ shell: pwsh
+ run: cmake --preset vs2022-x64-debug
+
+ - name: Build Debug
+ shell: pwsh
+ run: cmake --build --preset build-debug
+
+ - name: Run Native Tests
+ shell: pwsh
+ run: cmake --build --preset build-debug --target RUN_TESTS
+
+ ui-ubuntu:
+ name: React UI Build
+ runs-on: nubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install UI Dependencies
+ working-directory: ui
+ run: npm ci --no-audit --no-fund
+
+ - name: Build UI
+ working-directory: ui
+ run: npm run build
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index ed3e65e..1db12d4 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -1,442 +1,12 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import { GripVertical, Trash2 } from "lucide-react";
+import { useEffect, useState } from "react";
-function KvList({ values }) {
- return (
-
- {values.map(([key, value]) => (
-
- ))}
-
- );
-}
-
-function FragmentRow({ label, value }) {
- return (
- <>
- {label}
- {value}
- >
- );
-}
-
-function postJson(path, payload) {
- return fetch(path, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
-}
-
-function moveItem(array, fromIndex, toIndex) {
- if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) {
- return array;
- }
-
- const copy = [...array];
- const [item] = copy.splice(fromIndex, 1);
- copy.splice(toIndex, 0, item);
- return copy;
-}
-
-function formatNumber(value, digits = 3) {
- return Number(value ?? 0).toFixed(digits);
-}
-
-function formatParameterValue(parameterType, value) {
- if (parameterType === "float") {
- return formatNumber(value);
- }
- if (parameterType === "vec2" || parameterType === "color") {
- return (value ?? []).map((item) => formatNumber(item)).join(", ");
- }
- if (parameterType === "bool") {
- return value ? "Enabled" : "Disabled";
- }
- return `${value ?? ""}`;
-}
-
-function valuesMatch(left, right) {
- return JSON.stringify(left) === JSON.stringify(right);
-}
-
-function ParameterField({ parameter, onParameterChange }) {
- const [draftValue, setDraftValue] = useState(parameter.value);
- const [appliedValue, setAppliedValue] = useState(parameter.value);
- const pendingTimeoutRef = useRef(null);
- const latestDraftRef = useRef(parameter.value);
- const lastSentAtRef = useRef(0);
- const isInteractingRef = useRef(false);
- const isDirtyRef = useRef(false);
-
- useEffect(() => {
- setDraftValue(parameter.value);
- setAppliedValue(parameter.value);
- latestDraftRef.current = parameter.value;
- lastSentAtRef.current = 0;
- isInteractingRef.current = false;
- isDirtyRef.current = false;
- }, [parameter.id]);
-
- useEffect(() => {
- setAppliedValue(parameter.value);
- latestDraftRef.current = draftValue;
- if (isDirtyRef.current && valuesMatch(parameter.value, latestDraftRef.current)) {
- isDirtyRef.current = false;
- }
- if (!isInteractingRef.current && !isDirtyRef.current) {
- setDraftValue(parameter.value);
- }
- }, [draftValue, parameter.value]);
-
- useEffect(() => {
- return () => {
- if (pendingTimeoutRef.current) {
- clearTimeout(pendingTimeoutRef.current);
- }
- };
- }, []);
-
- const sendValue = (value) => {
- setDraftValue(value);
- latestDraftRef.current = value;
- isDirtyRef.current = true;
- lastSentAtRef.current = Date.now();
- const request = onParameterChange(parameter.id, value);
- if (request && typeof request.then === "function") {
- request
- .then((response) => {
- if (response?.ok) {
- setAppliedValue(value);
- }
- })
- .catch(() => {
- // Keep showing the last confirmed value if the update failed.
- });
- }
- };
-
- const scheduleSendValue = (value, immediate = false) => {
- setDraftValue(value);
- latestDraftRef.current = value;
- isDirtyRef.current = true;
-
- if (pendingTimeoutRef.current) {
- clearTimeout(pendingTimeoutRef.current);
- pendingTimeoutRef.current = null;
- }
-
- const now = Date.now();
- const throttleMs = 90;
- const elapsed = now - lastSentAtRef.current;
-
- if (immediate || elapsed >= throttleMs) {
- sendValue(value);
- return;
- }
-
- pendingTimeoutRef.current = setTimeout(() => {
- pendingTimeoutRef.current = null;
- sendValue(latestDraftRef.current);
- }, throttleMs - elapsed);
- };
-
- const flushScheduledValue = () => {
- if (pendingTimeoutRef.current) {
- clearTimeout(pendingTimeoutRef.current);
- pendingTimeoutRef.current = null;
- }
- sendValue(latestDraftRef.current);
- };
-
- const beginInteraction = () => {
- isInteractingRef.current = true;
- };
-
- const endInteraction = () => {
- isInteractingRef.current = false;
- flushScheduledValue();
- };
-
- const label = ;
- const isPending = !valuesMatch(draftValue, appliedValue);
- const appliedValueText = formatParameterValue(parameter.type, appliedValue);
-
- if (parameter.type === "float") {
- return (
-
- );
- }
-
- if (parameter.type === "vec2" || parameter.type === "color") {
- const componentCount = parameter.type === "color" ? 4 : 2;
- const values = [...(draftValue ?? [])];
- while (values.length < componentCount) {
- values.push(0);
- }
-
- return (
-
- );
- }
-
- if (parameter.type === "bool") {
- return (
-
- );
- }
-
- if (parameter.type === "enum") {
- return (
-
- {label}
-
-
- {isPending ? `Applied: ${appliedValueText}` : appliedValueText}
-
-
- );
- }
-
- return null;
-}
-
-function LayerCard({
- layer,
- index,
- shaders,
- expanded,
- isDragging,
- isDropTarget,
- onToggleExpanded,
- onDragStart,
- onDragEnd,
- onDragOver,
- onDrop,
- onRemove,
- onLayerParameterChange,
-}) {
- return (
- {
- event.preventDefault();
- onDragOver(layer.id);
- }}
- onDrop={(event) => {
- event.preventDefault();
- onDrop(event, layer.id, index);
- }}
- >
-
-
-
- {index + 1}
-
-
-
-
-
-
-
-
-
-
-
- {expanded ? (
-
-
-
-
-
-
- {layer.temporal?.enabled ? (
-
-
-
- {layer.temporal.historySource} history, requested {layer.temporal.requestedHistoryLength} frame{layer.temporal.requestedHistoryLength === 1 ? "" : "s"}, using {layer.temporal.effectiveHistoryLength}
-
-
- ) : (
-
-
-
Stateless shader
-
- )}
-
-
-
Parameters
-
-
-
- {layer.parameters.length > 0 ? (
-
- {layer.parameters.map((parameter) => (
-
onLayerParameterChange(layer.id, parameterId, value)}
- />
- ))}
-
- ) : (
-
This shader does not expose any user parameters.
- )}
-
- ) : null}
-
- );
-}
+import { LayerStack } from "./components/LayerStack";
+import { StackPresetToolbar } from "./components/StackPresetToolbar";
+import { StatusPanels } from "./components/StatusPanels";
+import { useRuntimeState } from "./hooks/useRuntimeState";
function App() {
- const [appState, setAppState] = useState(null);
+ const [appState, setAppState] = useRuntimeState();
const [pendingShaderId, setPendingShaderId] = useState("");
const [presetName, setPresetName] = useState("");
const [selectedPresetName, setSelectedPresetName] = useState("");
@@ -444,54 +14,6 @@ function App() {
const [dragLayerId, setDragLayerId] = useState(null);
const [dropTargetLayerId, setDropTargetLayerId] = useState(null);
- useEffect(() => {
- let mounted = true;
- let socket;
- let retryTimer;
-
- const connectWebSocket = () => {
- const protocol = window.location.protocol === "https:" ? "wss" : "ws";
- socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
- socket.onmessage = (event) => {
- try {
- const nextState = JSON.parse(event.data);
- if (mounted) {
- setAppState(nextState);
- }
- } catch (error) {
- console.error("Failed to parse state update", error);
- }
- };
- socket.onclose = () => {
- if (mounted) {
- retryTimer = window.setTimeout(connectWebSocket, 1000);
- }
- };
- };
-
- fetch("/api/state")
- .then((response) => response.json())
- .then((state) => {
- if (mounted) {
- setAppState(state);
- connectWebSocket();
- }
- })
- .catch((error) => {
- console.error("Failed to load initial state", error);
- });
-
- return () => {
- mounted = false;
- if (retryTimer) {
- window.clearTimeout(retryTimer);
- }
- if (socket) {
- socket.close();
- }
- };
- }, []);
-
const layers = appState?.layers ?? [];
const shaders = appState?.shaders ?? [];
const performance = appState?.performance ?? {};
@@ -521,60 +43,6 @@ function App() {
setExpandedLayerIds((current) => current.filter((layerId) => layerIds.has(layerId)));
}, [layers]);
- const expandedSet = useMemo(() => new Set(expandedLayerIds), [expandedLayerIds]);
-
- function updateLayerParameterOptimistically(layerId, parameterId, value) {
- return postJson("/api/layers/update-parameter", {
- layerId,
- parameterId,
- value,
- });
- }
-
- function toggleExpanded(layerId) {
- setExpandedLayerIds((current) =>
- current.includes(layerId) ? current.filter((id) => id !== layerId) : [...current, layerId],
- );
- }
-
- function removeLayer(layerId) {
- setExpandedLayerIds((current) => current.filter((id) => id !== layerId));
- postJson("/api/layers/remove", { layerId });
- }
-
- function handleDrop(event, targetLayerId, targetIndex) {
- const sourceLayerId = event.dataTransfer.getData("text/plain") || dragLayerId;
- if (!sourceLayerId || sourceLayerId === targetLayerId) {
- setDragLayerId(null);
- setDropTargetLayerId(null);
- return;
- }
-
- setAppState((current) => {
- if (!current?.layers) {
- return current;
- }
-
- const sourceIndex = current.layers.findIndex((layer) => layer.id === sourceLayerId);
- const destinationIndex = current.layers.findIndex((layer) => layer.id === targetLayerId);
- if (sourceIndex < 0 || destinationIndex < 0 || sourceIndex === destinationIndex) {
- return current;
- }
-
- return {
- ...current,
- layers: moveItem(current.layers, sourceIndex, destinationIndex),
- };
- });
-
- postJson("/api/layers/reorder", {
- layerId: sourceLayerId,
- targetIndex,
- });
- setDragLayerId(null);
- setDropTargetLayerId(null);
- }
-
if (!appState) {
return (
@@ -588,171 +56,29 @@ function App() {
return (
-
-
-
-
- setPresetName(event.target.value)}
- />
-
-
-
+
-
-
-
-
-
-
-
+
-
-
-
-
-
-
Runtime
-
-
-
-
-
Video
-
-
-
-
-
Compiler
-
{runtime.compileMessage || "No compiler output."}
-
-
-
-
-
-
Layers
-
Drag layers to reorder them. Each layer processes the output of the one above it.
-
-
-
- {layers.map((layer, index) => (
-
{
- setDragLayerId(null);
- setDropTargetLayerId(null);
- }}
- onDragOver={setDropTargetLayerId}
- onDrop={handleDrop}
- onRemove={removeLayer}
- onLayerParameterChange={updateLayerParameterOptimistically}
- />
- ))}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
);
}
diff --git a/ui/src/api/controlApi.js b/ui/src/api/controlApi.js
new file mode 100644
index 0000000..a124cf3
--- /dev/null
+++ b/ui/src/api/controlApi.js
@@ -0,0 +1,7 @@
+export function postJson(path, payload) {
+ return fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+}
diff --git a/ui/src/components/KvList.jsx b/ui/src/components/KvList.jsx
new file mode 100644
index 0000000..7a25222
--- /dev/null
+++ b/ui/src/components/KvList.jsx
@@ -0,0 +1,18 @@
+export function KvList({ values }) {
+ return (
+
+ {values.map(([key, value]) => (
+
+ ))}
+
+ );
+}
+
+function FragmentRow({ label, value }) {
+ return (
+ <>
+ {label}
+ {value}
+ >
+ );
+}
diff --git a/ui/src/components/LayerCard.jsx b/ui/src/components/LayerCard.jsx
new file mode 100644
index 0000000..0c8bea2
--- /dev/null
+++ b/ui/src/components/LayerCard.jsx
@@ -0,0 +1,154 @@
+import { GripVertical, Trash2 } from "lucide-react";
+
+import { postJson } from "../api/controlApi";
+import { ParameterField } from "./ParameterField";
+
+export function LayerCard({
+ layer,
+ index,
+ shaders,
+ expanded,
+ isDragging,
+ isDropTarget,
+ onToggleExpanded,
+ onDragStart,
+ onDragEnd,
+ onDragOver,
+ onDrop,
+ onRemove,
+ onLayerParameterChange,
+}) {
+ return (
+ {
+ event.preventDefault();
+ onDragOver(layer.id);
+ }}
+ onDrop={(event) => {
+ event.preventDefault();
+ onDrop(event, layer.id, index);
+ }}
+ >
+
+
+
+ {index + 1}
+
+
+
+
+
+
+
+
+
+
+
+ {expanded ? (
+
+
+
+
+
+
+ {layer.temporal?.enabled ? (
+
+
+
+ {layer.temporal.historySource} history, requested {layer.temporal.requestedHistoryLength} frame{layer.temporal.requestedHistoryLength === 1 ? "" : "s"}, using {layer.temporal.effectiveHistoryLength}
+
+
+ ) : (
+
+
+
Stateless shader
+
+ )}
+
+
+
Parameters
+
+
+
+ {layer.parameters.length > 0 ? (
+
+ {layer.parameters.map((parameter) => (
+
onLayerParameterChange(layer.id, parameterId, value)}
+ />
+ ))}
+
+ ) : (
+
This shader does not expose any user parameters.
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/ui/src/components/LayerStack.jsx b/ui/src/components/LayerStack.jsx
new file mode 100644
index 0000000..8f21f42
--- /dev/null
+++ b/ui/src/components/LayerStack.jsx
@@ -0,0 +1,152 @@
+import { postJson } from "../api/controlApi";
+import { LayerCard } from "./LayerCard";
+
+function moveItem(array, fromIndex, toIndex) {
+ if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) {
+ return array;
+ }
+
+ const copy = [...array];
+ const [item] = copy.splice(fromIndex, 1);
+ copy.splice(toIndex, 0, item);
+ return copy;
+}
+
+export function LayerStack({
+ dragLayerId,
+ dropTargetLayerId,
+ expandedLayerIds,
+ layers,
+ pendingShaderId,
+ setAppState,
+ setDragLayerId,
+ setDropTargetLayerId,
+ setExpandedLayerIds,
+ setPendingShaderId,
+ shaders,
+}) {
+ const expandedSet = new Set(expandedLayerIds);
+
+ function updateLayerParameterOptimistically(layerId, parameterId, value) {
+ return postJson("/api/layers/update-parameter", {
+ layerId,
+ parameterId,
+ value,
+ });
+ }
+
+ function toggleExpanded(layerId) {
+ setExpandedLayerIds((current) =>
+ current.includes(layerId) ? current.filter((id) => id !== layerId) : [...current, layerId],
+ );
+ }
+
+ function removeLayer(layerId) {
+ setExpandedLayerIds((current) => current.filter((id) => id !== layerId));
+ postJson("/api/layers/remove", { layerId });
+ }
+
+ function handleDrop(event, targetLayerId, targetIndex) {
+ const sourceLayerId = event.dataTransfer.getData("text/plain") || dragLayerId;
+ if (!sourceLayerId || sourceLayerId === targetLayerId) {
+ setDragLayerId(null);
+ setDropTargetLayerId(null);
+ return;
+ }
+
+ setAppState((current) => {
+ if (!current?.layers) {
+ return current;
+ }
+
+ const sourceIndex = current.layers.findIndex((layer) => layer.id === sourceLayerId);
+ const destinationIndex = current.layers.findIndex((layer) => layer.id === targetLayerId);
+ if (sourceIndex < 0 || destinationIndex < 0 || sourceIndex === destinationIndex) {
+ return current;
+ }
+
+ return {
+ ...current,
+ layers: moveItem(current.layers, sourceIndex, destinationIndex),
+ };
+ });
+
+ postJson("/api/layers/reorder", {
+ layerId: sourceLayerId,
+ targetIndex,
+ });
+ setDragLayerId(null);
+ setDropTargetLayerId(null);
+ }
+
+ return (
+
+
+
Layers
+
Drag layers to reorder them. Each layer processes the output of the one above it.
+
+
+
+ {layers.map((layer, index) => (
+
{
+ setDragLayerId(null);
+ setDropTargetLayerId(null);
+ }}
+ onDragOver={setDropTargetLayerId}
+ onDrop={handleDrop}
+ onRemove={removeLayer}
+ onLayerParameterChange={updateLayerParameterOptimistically}
+ />
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/ParameterField.jsx b/ui/src/components/ParameterField.jsx
new file mode 100644
index 0000000..1651718
--- /dev/null
+++ b/ui/src/components/ParameterField.jsx
@@ -0,0 +1,130 @@
+import { useThrottledParameterValue } from "../hooks/useThrottledParameterValue";
+import { ParameterValueDisplay } from "./ParameterValueDisplay";
+
+export function ParameterField({ parameter, onParameterChange }) {
+ const {
+ appliedValue,
+ beginInteraction,
+ draftValue,
+ endInteraction,
+ isPending,
+ scheduleSendValue,
+ sendValue,
+ } = useThrottledParameterValue(parameter, onParameterChange);
+
+ const label = ;
+
+ if (parameter.type === "float") {
+ return (
+
+ );
+ }
+
+ if (parameter.type === "vec2" || parameter.type === "color") {
+ const componentCount = parameter.type === "color" ? 4 : 2;
+ const values = [...(draftValue ?? [])];
+ while (values.length < componentCount) {
+ values.push(0);
+ }
+
+ return (
+
+ );
+ }
+
+ if (parameter.type === "bool") {
+ return (
+
+ );
+ }
+
+ if (parameter.type === "enum") {
+ return (
+
+ {label}
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/ui/src/components/ParameterValueDisplay.jsx b/ui/src/components/ParameterValueDisplay.jsx
new file mode 100644
index 0000000..8dab0f5
--- /dev/null
+++ b/ui/src/components/ParameterValueDisplay.jsx
@@ -0,0 +1,25 @@
+function formatNumber(value, digits = 3) {
+ return Number(value ?? 0).toFixed(digits);
+}
+
+export function formatParameterValue(parameterType, value) {
+ if (parameterType === "float") {
+ return formatNumber(value);
+ }
+ if (parameterType === "vec2" || parameterType === "color") {
+ return (value ?? []).map((item) => formatNumber(item)).join(", ");
+ }
+ if (parameterType === "bool") {
+ return value ? "Enabled" : "Disabled";
+ }
+ return `${value ?? ""}`;
+}
+
+export function ParameterValueDisplay({ parameterType, value, pending }) {
+ const valueText = formatParameterValue(parameterType, value);
+ return (
+
+ {pending ? `Applied: ${valueText}` : valueText}
+
+ );
+}
diff --git a/ui/src/components/StackPresetToolbar.jsx b/ui/src/components/StackPresetToolbar.jsx
new file mode 100644
index 0000000..61f3639
--- /dev/null
+++ b/ui/src/components/StackPresetToolbar.jsx
@@ -0,0 +1,75 @@
+import { postJson } from "../api/controlApi";
+
+export function StackPresetToolbar({
+ presetName,
+ selectedPresetName,
+ stackPresets,
+ onPresetNameChange,
+ onSelectedPresetNameChange,
+}) {
+ return (
+
+
+
+
+ onPresetNameChange(event.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/StatusPanels.jsx b/ui/src/components/StatusPanels.jsx
new file mode 100644
index 0000000..3f859c1
--- /dev/null
+++ b/ui/src/components/StatusPanels.jsx
@@ -0,0 +1,44 @@
+import { KvList } from "./KvList";
+
+function formatNumber(value, digits = 3) {
+ return Number(value ?? 0).toFixed(digits);
+}
+
+export function StatusPanels({ app, performance, runtime, video }) {
+ return (
+
+
+
Runtime
+
+
+
+
+
Video
+
+
+
+
+
Compiler
+
{runtime.compileMessage || "No compiler output."}
+
+
+ );
+}
diff --git a/ui/src/hooks/useRuntimeState.js b/ui/src/hooks/useRuntimeState.js
new file mode 100644
index 0000000..59a367c
--- /dev/null
+++ b/ui/src/hooks/useRuntimeState.js
@@ -0,0 +1,55 @@
+import { useEffect, useState } from "react";
+
+export function useRuntimeState() {
+ const [appState, setAppState] = useState(null);
+
+ useEffect(() => {
+ let mounted = true;
+ let socket;
+ let retryTimer;
+
+ const connectWebSocket = () => {
+ const protocol = window.location.protocol === "https:" ? "wss" : "ws";
+ socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
+ socket.onmessage = (event) => {
+ try {
+ const nextState = JSON.parse(event.data);
+ if (mounted) {
+ setAppState(nextState);
+ }
+ } catch (error) {
+ console.error("Failed to parse state update", error);
+ }
+ };
+ socket.onclose = () => {
+ if (mounted) {
+ retryTimer = window.setTimeout(connectWebSocket, 1000);
+ }
+ };
+ };
+
+ fetch("/api/state")
+ .then((response) => response.json())
+ .then((state) => {
+ if (mounted) {
+ setAppState(state);
+ connectWebSocket();
+ }
+ })
+ .catch((error) => {
+ console.error("Failed to load initial state", error);
+ });
+
+ return () => {
+ mounted = false;
+ if (retryTimer) {
+ window.clearTimeout(retryTimer);
+ }
+ if (socket) {
+ socket.close();
+ }
+ };
+ }, []);
+
+ return [appState, setAppState];
+}
diff --git a/ui/src/hooks/useThrottledParameterValue.js b/ui/src/hooks/useThrottledParameterValue.js
new file mode 100644
index 0000000..c8e8121
--- /dev/null
+++ b/ui/src/hooks/useThrottledParameterValue.js
@@ -0,0 +1,114 @@
+import { useEffect, useRef, useState } from "react";
+
+function valuesMatch(left, right) {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
+export function useThrottledParameterValue(parameter, onParameterChange) {
+ const [draftValue, setDraftValue] = useState(parameter.value);
+ const [appliedValue, setAppliedValue] = useState(parameter.value);
+ const pendingTimeoutRef = useRef(null);
+ const latestDraftRef = useRef(parameter.value);
+ const lastSentAtRef = useRef(0);
+ const isInteractingRef = useRef(false);
+ const isDirtyRef = useRef(false);
+
+ useEffect(() => {
+ setDraftValue(parameter.value);
+ setAppliedValue(parameter.value);
+ latestDraftRef.current = parameter.value;
+ lastSentAtRef.current = 0;
+ isInteractingRef.current = false;
+ isDirtyRef.current = false;
+ }, [parameter.id]);
+
+ useEffect(() => {
+ setAppliedValue(parameter.value);
+ latestDraftRef.current = draftValue;
+ if (isDirtyRef.current && valuesMatch(parameter.value, latestDraftRef.current)) {
+ isDirtyRef.current = false;
+ }
+ if (!isInteractingRef.current && !isDirtyRef.current) {
+ setDraftValue(parameter.value);
+ }
+ }, [draftValue, parameter.value]);
+
+ useEffect(() => {
+ return () => {
+ if (pendingTimeoutRef.current) {
+ clearTimeout(pendingTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ const sendValue = (value) => {
+ setDraftValue(value);
+ latestDraftRef.current = value;
+ isDirtyRef.current = true;
+ lastSentAtRef.current = Date.now();
+ const request = onParameterChange(parameter.id, value);
+ if (request && typeof request.then === "function") {
+ request
+ .then((response) => {
+ if (response?.ok) {
+ setAppliedValue(value);
+ }
+ })
+ .catch(() => {
+ // Keep showing the last confirmed value if the update failed.
+ });
+ }
+ };
+
+ const scheduleSendValue = (value, immediate = false) => {
+ setDraftValue(value);
+ latestDraftRef.current = value;
+ isDirtyRef.current = true;
+
+ if (pendingTimeoutRef.current) {
+ clearTimeout(pendingTimeoutRef.current);
+ pendingTimeoutRef.current = null;
+ }
+
+ const now = Date.now();
+ const throttleMs = 90;
+ const elapsed = now - lastSentAtRef.current;
+
+ if (immediate || elapsed >= throttleMs) {
+ sendValue(value);
+ return;
+ }
+
+ pendingTimeoutRef.current = setTimeout(() => {
+ pendingTimeoutRef.current = null;
+ sendValue(latestDraftRef.current);
+ }, throttleMs - elapsed);
+ };
+
+ const flushScheduledValue = () => {
+ if (pendingTimeoutRef.current) {
+ clearTimeout(pendingTimeoutRef.current);
+ pendingTimeoutRef.current = null;
+ }
+ sendValue(latestDraftRef.current);
+ };
+
+ const beginInteraction = () => {
+ isInteractingRef.current = true;
+ };
+
+ const endInteraction = () => {
+ isInteractingRef.current = false;
+ flushScheduledValue();
+ };
+
+ return {
+ appliedValue,
+ beginInteraction,
+ draftValue,
+ endInteraction,
+ isPending: !valuesMatch(draftValue, appliedValue),
+ scheduleSendValue,
+ sendValue,
+ };
+}