Control ui adjsutments
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 1m54s
CI / Windows Release Package (push) Successful in 2m9s

This commit is contained in:
2026-05-08 13:54:02 +10:00
parent 7035cde8c8
commit bc536bd751
10 changed files with 135 additions and 120 deletions

14
ui/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@uiw/color-convert": "^2.10.1",
"@uiw/react-color-wheel": "^2.10.1",
"fuse.js": "^7.3.0",
"lucide-react": "^0.511.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -1429,6 +1430,19 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/fuse.js": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz",
"integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/krisk"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@uiw/color-convert": "^2.10.1",
"@uiw/react-color-wheel": "^2.10.1",
"fuse.js": "^7.3.0",
"lucide-react": "^0.511.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"

View File

@@ -23,7 +23,6 @@ function AppFooter() {
function App() {
const [appState, setAppState] = useRuntimeState();
const [pendingShaderId, setPendingShaderId] = useState("");
const [presetName, setPresetName] = useState("");
const [selectedPresetName, setSelectedPresetName] = useState("");
const [expandedLayerIds, setExpandedLayerIds] = useState([]);
@@ -38,14 +37,6 @@ function App() {
const app = appState?.app ?? {};
const stackPresets = appState?.stackPresets ?? [];
useEffect(() => {
if (!pendingShaderId && shaders.length > 0) {
setPendingShaderId(shaders[0].id);
} else if (pendingShaderId && !shaders.some((shader) => shader.id === pendingShaderId)) {
setPendingShaderId(shaders[0]?.id ?? "");
}
}, [pendingShaderId, shaders]);
useEffect(() => {
if (!selectedPresetName && stackPresets.length > 0) {
setSelectedPresetName(stackPresets[0]);
@@ -123,12 +114,10 @@ function App() {
dropTargetLayerId={dropTargetLayerId}
expandedLayerIds={expandedLayerIds}
layers={layers}
pendingShaderId={pendingShaderId}
setAppState={setAppState}
setDragLayerId={setDragLayerId}
setDropTargetLayerId={setDropTargetLayerId}
setExpandedLayerIds={setExpandedLayerIds}
setPendingShaderId={setPendingShaderId}
shaders={shaders}
/>

View File

@@ -1,4 +1,4 @@
import { GripVertical, Trash2 } from "lucide-react";
import { EyeOff, GripVertical, RotateCcw, SlidersHorizontal, Trash2 } from "lucide-react";
import { postJson } from "../api/controlApi";
import { ParameterField } from "./ParameterField";
@@ -71,11 +71,13 @@ export function LayerCard({
})
}
/>
<EyeOff size={15} strokeWidth={1.8} aria-hidden="true" />
<span>Bypass</span>
</label>
<button type="button" onClick={() => onToggleExpanded(layer.id)}>
{expanded ? "Hide" : "Controls"}
<button type="button" className="button-with-icon" onClick={() => onToggleExpanded(layer.id)}>
<SlidersHorizontal size={16} strokeWidth={1.9} aria-hidden="true" />
<span>{expanded ? "Hide" : "Controls"}</span>
</button>
<button
type="button"
@@ -116,10 +118,12 @@ export function LayerCard({
<h3>Parameters</h3>
<button
type="button"
className="button-with-icon"
disabled={layer.parameters.length === 0}
onClick={() => postJson("/api/layers/reset-parameters", { layerId: layer.id })}
>
Reset
<RotateCcw size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Reset</span>
</button>
</div>

View File

@@ -18,12 +18,10 @@ export function LayerStack({
dropTargetLayerId,
expandedLayerIds,
layers,
pendingShaderId,
setAppState,
setDragLayerId,
setDropTargetLayerId,
setExpandedLayerIds,
setPendingShaderId,
shaders,
}) {
const expandedSet = new Set(expandedLayerIds);
@@ -118,27 +116,13 @@ export function LayerStack({
<span className="layer-card__index">+</span>
<div className="layer-card__title layer-card__title--static">Add Layer</div>
</div>
<div className="layer-card__actions">
<button
type="button"
disabled={!pendingShaderId}
onClick={() => {
if (pendingShaderId) {
postJson("/api/layers/add", { shaderId: pendingShaderId });
}
}}
>
Add
</button>
</div>
</div>
<div className="layer-card__body">
<div className="layer-card__field">
<ShaderPicker
id="add-layer"
shaders={shaders}
value={pendingShaderId}
onChange={setPendingShaderId}
onAdd={(shaderId) => postJson("/api/layers/add", { shaderId })}
/>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import Wheel from "@uiw/react-color-wheel";
import { hsvaToRgba, rgbaToHsva } from "@uiw/color-convert";
import { Copy, RotateCcw } from "lucide-react";
import { Copy, RotateCcw, Zap } from "lucide-react";
import { useThrottledParameterValue } from "../hooks/useThrottledParameterValue";
import { ParameterValueDisplay } from "./ParameterValueDisplay";
@@ -325,10 +325,11 @@ export function ParameterField({ layer, parameter, onParameterChange }) {
{header}
<button
type="button"
className="parameter__trigger"
className="button-with-icon parameter__trigger"
onClick={() => sendValue(triggerCount + 1)}
>
Trigger
<Zap size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Trigger</span>
</button>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
</section>

View File

@@ -1,16 +1,19 @@
import { ChevronDown, Search } from "lucide-react";
import Fuse from "fuse.js";
import { Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
function matchesShader(shader, query) {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return true;
}
return [shader.name, shader.id, shader.category, shader.description, shader.error]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedQuery));
}
const shaderSearchOptions = {
threshold: 0.38,
ignoreLocation: true,
minMatchCharLength: 2,
keys: [
{ name: "name", weight: 0.45 },
{ name: "id", weight: 0.25 },
{ name: "category", weight: 0.15 },
{ name: "description", weight: 0.1 },
{ name: "error", weight: 0.05 },
],
};
function shaderSummary(shader) {
if (!shader) {
@@ -37,86 +40,78 @@ function ShaderOptionContent({ shader }) {
);
}
export function ShaderPicker({ id, label = "Shader", shaders, value, onChange }) {
export function ShaderPicker({ id, label = "Shader", shaders, onAdd }) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const filteredShaders = useMemo(
() => shaders.filter((shader) => matchesShader(shader, query)),
[query, shaders],
const shaderSearch = useMemo(
() => new Fuse(shaders, shaderSearchOptions),
[shaders],
);
const selectedShader = shaders.find((shader) => shader.id === value);
const filteredShaders = useMemo(
() => {
const normalizedQuery = query.trim();
if (!normalizedQuery) {
return shaders;
}
return shaderSearch.search(normalizedQuery).map((result) => result.item);
},
[query, shaderSearch, shaders],
);
return (
<div className="shader-picker">
<div className="shader-picker__topline">
<label id={`${id}-label`}>{label}</label>
{selectedShader ? <span className="shader-picker__selected">{selectedShader.name}</span> : null}
</div>
<button
type="button"
className="shader-picker__trigger"
aria-labelledby={`${id}-label`}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<span>
<span className="shader-picker__option-head">
<span className="shader-picker__name">{selectedShader?.name ?? "Choose shader"}</span>
{selectedShader?.available === false ? (
<span className="shader-picker__category shader-picker__category--error">Error</span>
) : selectedShader?.category ? (
<span className="shader-picker__category">{selectedShader.category}</span>
) : null}
</span>
<span className="shader-picker__meta">{shaderSummary(selectedShader)}</span>
</span>
<ChevronDown size={16} strokeWidth={1.75} aria-hidden="true" />
</button>
<div className="shader-picker__popover">
<div className="shader-picker__search">
<Search size={16} strokeWidth={1.75} aria-hidden="true" />
<input
id={`${id}-search`}
type="text"
value={query}
placeholder="Search shaders"
onChange={(event) => setQuery(event.target.value)}
/>
</div>
{open ? (
<div className="shader-picker__popover">
<div className="shader-picker__search">
<Search size={16} strokeWidth={1.75} aria-hidden="true" />
<input
id={`${id}-search`}
type="text"
value={query}
placeholder="Search shaders"
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className="shader-picker__list" role="listbox" aria-label={label}>
{filteredShaders.length > 0 ? (
filteredShaders.map((shader) => (
<div className="shader-picker__list" role="list" aria-labelledby={`${id}-label`}>
{filteredShaders.length > 0 ? (
filteredShaders.map((shader) => (
<div
key={shader.id}
className={`shader-picker__option${shader.available === false ? " shader-picker__option--unavailable" : ""}`}
role="listitem"
>
<span className="shader-picker__option-copy">
<ShaderOptionContent shader={shader} />
</span>
<button
key={shader.id}
type="button"
className={`shader-picker__option${shader.id === value ? " shader-picker__option--selected" : ""}${shader.available === false ? " shader-picker__option--unavailable" : ""}`}
role="option"
aria-selected={shader.id === value}
className="icon-button shader-picker__add"
title={`Add ${shader.name}`}
aria-label={`Add ${shader.name}`}
disabled={shader.available === false}
onClick={() => {
if (shader.available === false) {
return;
}
onChange(shader.id);
setOpen(false);
onAdd(shader.id);
setQuery("");
}}
>
<ShaderOptionContent shader={shader} />
<Plus size={16} strokeWidth={1.9} />
</button>
))
) : (
<div className="shader-picker__empty">No shaders found</div>
)}
</div>
</div>
))
) : (
<div className="shader-picker__empty">No shaders found</div>
)}
</div>
) : null}
</div>
</div>
);
}

View File

@@ -1,3 +1,5 @@
import { FolderOpen, RefreshCw, Save } from "lucide-react";
import { postJson } from "../api/controlApi";
export function StackPresetToolbar({
@@ -14,8 +16,13 @@ export function StackPresetToolbar({
<h3>Stack presets</h3>
<p className="muted">Save or recall the current layer chain.</p>
</div>
<button type="button" className="stack-panel__reload" onClick={() => postJson("/api/reload", {})}>
Reload shader
<button
type="button"
className="button-with-icon stack-panel__reload"
onClick={() => postJson("/api/reload", {})}
>
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Reload shader</span>
</button>
</div>
@@ -32,6 +39,7 @@ export function StackPresetToolbar({
/>
<button
type="button"
className="button-with-icon"
disabled={!presetName.trim()}
onClick={() => {
const trimmedName = presetName.trim();
@@ -44,7 +52,8 @@ export function StackPresetToolbar({
);
}}
>
Save
<Save size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Save</span>
</button>
</div>
</div>
@@ -66,6 +75,7 @@ export function StackPresetToolbar({
</select>
<button
type="button"
className="button-with-icon"
disabled={!selectedPresetName}
onClick={() => {
if (selectedPresetName) {
@@ -73,7 +83,8 @@ export function StackPresetToolbar({
}
}}
>
Recall
<FolderOpen size={16} strokeWidth={1.9} aria-hidden="true" />
<span>Recall</span>
</button>
</div>
</div>

View File

@@ -156,6 +156,17 @@ button:active:not(:disabled) {
transform: translateY(1px);
}
.button-with-icon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
}
.button-with-icon svg {
flex: 0 0 auto;
}
button:disabled,
input:disabled,
select:disabled {
@@ -746,7 +757,7 @@ pre {
.shader-picker__list {
display: grid;
gap: 0.45rem;
max-height: 250px;
max-height: min(52vh, 520px);
overflow-y: auto;
padding: 0.5rem;
border: 1px solid var(--app-border);
@@ -754,22 +765,22 @@ pre {
}
.shader-picker__option {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.25rem;
align-items: center;
min-height: 4.25rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--app-border);
text-align: left;
background: #182232;
border-color: var(--app-border);
align-content: start;
line-height: 1.25;
}
.shader-picker__option--selected {
background: #203b54;
border-color: var(--app-primary);
box-shadow: inset 0 0 0 1px var(--app-primary-soft);
.shader-picker__option-copy {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.shader-picker__option--unavailable {
@@ -778,16 +789,16 @@ pre {
opacity: 1;
}
.shader-picker__option:disabled {
cursor: not-allowed;
opacity: 1;
}
.shader-picker__option--unavailable .shader-picker__name,
.shader-picker__option--unavailable .shader-picker__meta {
color: #ffd0cf;
}
.shader-picker__add {
flex: 0 0 auto;
margin-left: 0.5rem;
}
.shader-picker__name,
.shader-picker__meta {
min-width: 0;
@@ -1040,6 +1051,11 @@ pre {
min-height: 36px;
}
.toggle svg {
flex: 0 0 auto;
color: var(--app-muted);
}
.toggle--compact {
min-height: auto;
}