Skip to content

Commit da60fdd

Browse files
committed
feat(shader_ui_controls): add dropdown control
1 parent 1ae7b4d commit da60fdd

File tree

4 files changed

+254
-0
lines changed

4 files changed

+254
-0
lines changed

src/webgl/shader_ui_controls.browser_test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,30 @@ void main() {
150150
});
151151
});
152152

153+
it("reports only the invalid options error for empty dropdown options", () => {
154+
const code = `
155+
#uicontrol uint choice dropdown(options=[])
156+
void main() {
157+
}
158+
`;
159+
const newCode = `
160+
161+
void main() {
162+
}
163+
`;
164+
expect(parseShaderUiControls(code)).toEqual({
165+
source: code,
166+
code: newCode,
167+
errors: [
168+
{
169+
line: 1,
170+
message: "Expected options argument to be a non-empty array of strings",
171+
},
172+
],
173+
controls: new Map(),
174+
});
175+
});
176+
153177
it("handles color control", () => {
154178
const code = `
155179
#uicontrol vec3 color color(default="red")
@@ -1010,3 +1034,109 @@ void main() {
10101034
});
10111035
});
10121036
});
1037+
1038+
describe("parseShaderUiControls dropdown", () => {
1039+
it("handles basic dropdown control", () => {
1040+
const code = `
1041+
#uicontrol uint myMode dropdown(options=["one", "two", "three"])
1042+
void main() {
1043+
}
1044+
`;
1045+
const newCode = `
1046+
1047+
void main() {
1048+
}
1049+
`;
1050+
expect(parseShaderUiControls(code)).toEqual({
1051+
source: code,
1052+
code: newCode,
1053+
errors: [],
1054+
controls: new Map([
1055+
[
1056+
"myMode",
1057+
{
1058+
type: "dropdown",
1059+
valueType: "uint",
1060+
options: ["one", "two", "three"],
1061+
default: 0,
1062+
},
1063+
],
1064+
]),
1065+
});
1066+
});
1067+
1068+
it("handles explicit default index", () => {
1069+
const code = `
1070+
#uicontrol uint myMode dropdown(options=["a", "b", "c"], default=2)
1071+
void main() {
1072+
}
1073+
`;
1074+
const newCode = `
1075+
1076+
void main() {
1077+
}
1078+
`;
1079+
expect(parseShaderUiControls(code)).toEqual({
1080+
source: code,
1081+
code: newCode,
1082+
errors: [],
1083+
controls: new Map([
1084+
[
1085+
"myMode",
1086+
{
1087+
type: "dropdown",
1088+
valueType: "uint",
1089+
options: ["a", "b", "c"],
1090+
default: 2,
1091+
},
1092+
],
1093+
]),
1094+
});
1095+
});
1096+
1097+
it("errors on wrong type", () => {
1098+
const code = `
1099+
#uicontrol float myMode dropdown(options=["x", "y"])
1100+
void main() {
1101+
}
1102+
`;
1103+
const result = parseShaderUiControls(code);
1104+
expect(result.errors.length).toBeGreaterThan(0);
1105+
expect(result.errors[0].message).toContain("type must be uint");
1106+
});
1107+
1108+
it("errors when options is missing", () => {
1109+
const code = `
1110+
#uicontrol uint myMode dropdown()
1111+
void main() {
1112+
}
1113+
`;
1114+
const result = parseShaderUiControls(code);
1115+
expect(result.errors.length).toBeGreaterThan(0);
1116+
expect(result.errors[0].message).toContain("options must be specified");
1117+
});
1118+
1119+
it("errors when options is an empty array", () => {
1120+
const code = `
1121+
#uicontrol uint myMode dropdown(options=[])
1122+
void main() {
1123+
}
1124+
`;
1125+
const result = parseShaderUiControls(code);
1126+
expect(result.errors.length).toBeGreaterThan(0);
1127+
expect(result.errors[0].message).toContain(
1128+
"non-empty array of strings",
1129+
);
1130+
});
1131+
1132+
it("errors when default is out of range", () => {
1133+
const code = `
1134+
#uicontrol uint myMode dropdown(options=["x", "y"], default=5)
1135+
void main() {
1136+
}
1137+
`;
1138+
const result = parseShaderUiControls(code);
1139+
expect(result.errors.length).toBeGreaterThan(0);
1140+
expect(result.errors[0].message).toContain("out of range");
1141+
});
1142+
});

src/webgl/shader_ui_controls.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ export interface ShaderCheckboxControl {
111111
default: boolean;
112112
}
113113

114+
export interface ShaderDropdownControl {
115+
type: "dropdown";
116+
valueType: "uint";
117+
options: string[];
118+
default: number;
119+
}
120+
114121
export interface ShaderTransferFunctionControl {
115122
type: "transferFunction";
116123
dataType: DataType;
@@ -123,6 +130,7 @@ export type ShaderUiControl =
123130
| ShaderImageInvlerpControl
124131
| ShaderPropertyInvlerpControl
125132
| ShaderCheckboxControl
133+
| ShaderDropdownControl
126134
| ShaderTransferFunctionControl;
127135

128136
export interface ShaderControlParseError {
@@ -375,6 +383,63 @@ function parseCheckboxDirective(
375383
};
376384
}
377385

386+
function parseDropdownDirective(
387+
valueType: string,
388+
parameters: DirectiveParameters,
389+
): DirectiveParseResult {
390+
const errors: string[] = [];
391+
if (valueType !== "uint") {
392+
errors.push("type must be uint");
393+
}
394+
let options: string[] | undefined;
395+
let defaultValue = 0;
396+
for (const [key, value] of parameters) {
397+
if (key === "options") {
398+
if (
399+
!Array.isArray(value) ||
400+
value.length === 0 ||
401+
!value.every((v) => typeof v === "string")
402+
) {
403+
errors.push(
404+
"Expected options argument to be a non-empty array of strings",
405+
);
406+
} else {
407+
options = value as string[];
408+
}
409+
} else if (key === "default") {
410+
if (!Number.isInteger(value) || (value as number) < 0) {
411+
errors.push("Expected default argument to be a non-negative integer");
412+
} else {
413+
defaultValue = value as number;
414+
}
415+
} else {
416+
errors.push(`Invalid parameter: ${key}`);
417+
}
418+
}
419+
if (!parameters.has("options")) {
420+
errors.push("options must be specified");
421+
}
422+
if (options !== undefined && defaultValue >= options.length) {
423+
errors.push(
424+
`default index ${defaultValue} is out of range [0, ${
425+
options.length - 1
426+
}]`,
427+
);
428+
}
429+
if (errors.length > 0) {
430+
return { errors };
431+
}
432+
return {
433+
control: {
434+
type: "dropdown",
435+
valueType: "uint",
436+
options: options!,
437+
default: defaultValue,
438+
} as ShaderDropdownControl,
439+
errors: undefined,
440+
};
441+
}
442+
378443
function parseColorDirective(
379444
valueType: string,
380445
parameters: DirectiveParameters,
@@ -686,6 +751,7 @@ const controlParsers = new Map<
686751
["color", parseColorDirective],
687752
["invlerp", parseInvlerpDirective],
688753
["checkbox", parseCheckboxDirective],
754+
["dropdown", parseDropdownDirective],
689755
["transferFunction", parseTransferFunctionDirective],
690756
]);
691757

@@ -1259,6 +1325,21 @@ function getControlTrackable(control: ShaderUiControl): {
12591325
trackable: new TrackableBoolean(control.default),
12601326
getBuilderValue: (value) => ({ value }),
12611327
};
1328+
case "dropdown": {
1329+
const { options } = control;
1330+
return {
1331+
trackable: new TrackableValue<number>(control.default, (x) => {
1332+
const v = verifyInt(x);
1333+
if (v < 0 || v >= options.length) {
1334+
throw new Error(
1335+
`${v} is outside valid range [0, ${options.length - 1}]`,
1336+
);
1337+
}
1338+
return v;
1339+
}),
1340+
getBuilderValue: () => null,
1341+
};
1342+
}
12621343
case "transferFunction":
12631344
return {
12641345
trackable: new TrackableTransferFunctionParameters(
@@ -1649,6 +1730,9 @@ function setControlInShader(
16491730
case "checkbox":
16501731
// Value is hard-coded in shader.
16511732
break;
1733+
case "dropdown":
1734+
gl.uniform1ui(uniform, value);
1735+
break;
16521736
case "transferFunction":
16531737
enableTransferFunctionShader(
16541738
shader,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { UserLayer } from "#src/layer/index.js";
2+
import type { WatchableValueInterface } from "#src/trackable_value.js";
3+
import type { LayerControlFactory } from "#src/widget/layer_control.js";
4+
5+
export function dropdownLayerControl<LayerType extends UserLayer>(
6+
getter: (layer: LayerType) => {
7+
value: WatchableValueInterface<number>;
8+
options: string[];
9+
},
10+
): LayerControlFactory<LayerType, HTMLSelectElement> {
11+
return {
12+
makeControl: (layer, context) => {
13+
const { value, options } = getter(layer);
14+
const select = document.createElement("select");
15+
for (const [i, label] of options.entries()) {
16+
const opt = document.createElement("option");
17+
opt.value = String(i);
18+
opt.textContent = label;
19+
select.appendChild(opt);
20+
}
21+
select.value = String(value.value);
22+
context.registerDisposer(
23+
value.changed.add(() => {
24+
select.value = String(value.value);
25+
}),
26+
);
27+
select.addEventListener("change", () => {
28+
value.value = parseInt(select.value, 10);
29+
});
30+
return { control: select, controlElement: select };
31+
},
32+
activateTool: (_activation, _control) => {},
33+
};
34+
}

src/widget/shader_controls.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
import { channelInvlerpLayerControl } from "#src/widget/layer_control_channel_invlerp.js";
4040
import { checkboxLayerControl } from "#src/widget/layer_control_checkbox.js";
4141
import { colorLayerControl } from "#src/widget/layer_control_color.js";
42+
import { dropdownLayerControl } from "#src/widget/layer_control_dropdown.js";
4243
import { propertyInvlerpLayerControl } from "#src/widget/layer_control_property_invlerp.js";
4344
import { rangeLayerControl } from "#src/widget/layer_control_range.js";
4445
import { Tab } from "#src/widget/tab_view.js";
@@ -73,6 +74,11 @@ function getShaderLayerControlFactory<LayerType extends UserLayer>(
7374
return colorLayerControl(() => controlState.trackable);
7475
case "checkbox":
7576
return checkboxLayerControl(() => controlState.trackable);
77+
case "dropdown":
78+
return dropdownLayerControl(() => ({
79+
value: controlState.trackable,
80+
options: control.options,
81+
}));
7682
case "imageInvlerp": {
7783
return channelInvlerpLayerControl(() => ({
7884
dataType: control.dataType,

0 commit comments

Comments
 (0)