Allow extensions to define labels for properties with string selectors (#7825)

This commit is contained in:
D8H
2025-09-12 18:30:10 +02:00
committed by GitHub
parent 2befc9781b
commit 58e35cfaf5
12 changed files with 213 additions and 132 deletions

View File

@@ -248,12 +248,13 @@ gd::String PropertyFunctionGenerator::GetStringifiedExtraInfo(
gd::String arrayString;
arrayString += "[";
bool isFirst = true;
for (const gd::String &choice : property.GetExtraInfo()) {
for (const auto &choice : property.GetChoices()) {
if (!isFirst) {
arrayString += ",";
}
isFirst = false;
arrayString += "\"" + choice + "\"";
// TODO Handle labels (and search "choice label")
arrayString += "\"" + choice.GetValue() + "\"";
}
arrayString += "]";
return arrayString;

View File

@@ -34,6 +34,20 @@ void PropertyDescriptor::SerializeTo(SerializerElement& element) const {
}
}
if (!choices.empty()
// Compatibility with GD <= 5.5.239
|| !extraInformation.empty()
// end of compatibility code
) {
SerializerElement &choicesElement = element.AddChild("choices");
choicesElement.ConsiderAsArrayOf("choice");
for (const auto &choice : choices) {
auto &choiceElement = choicesElement.AddChild("Choice");
choiceElement.SetStringAttribute("value", choice.GetValue());
choiceElement.SetStringAttribute("label", choice.GetLabel());
}
}
if (hidden) {
element.AddChild("hidden").SetBoolValue(hidden);
}
@@ -80,6 +94,26 @@ void PropertyDescriptor::UnserializeFrom(const SerializerElement& element) {
extraInformationElement.GetChild(i).GetStringValue());
}
if (element.HasChild("choices")) {
choices.clear();
const SerializerElement &choicesElement = element.GetChild("choices");
choicesElement.ConsiderAsArrayOf("choice");
for (std::size_t i = 0; i < choicesElement.GetChildrenCount(); ++i) {
auto &choiceElement = choicesElement.GetChild(i);
AddChoice(choiceElement.GetStringAttribute("value"),
choiceElement.GetStringAttribute("label"));
}
}
// Compatibility with GD <= 5.5.239
else if (type == "Choice") {
choices.clear();
for (auto &choiceValue : extraInformation) {
AddChoice(choiceValue, choiceValue);
}
extraInformation.clear();
}
// end of compatibility code
hidden = element.HasChild("hidden")
? element.GetChild("hidden").GetBoolValue()
: false;

View File

@@ -116,6 +116,11 @@ class GD_CORE_API PropertyDescriptor {
return *this;
}
PropertyDescriptor& ClearChoices() {
choices.clear();
return *this;
}
PropertyDescriptor& AddChoice(const gd::String& value,
const gd::String& label) {
choices.push_back(PropertyDescriptorChoice(value, label));

View File

@@ -139,8 +139,8 @@ TEST_CASE("PropertyFunctionGenerator", "[common]") {
.SetLabel("Dot shape")
.SetDescription("The shape is used for collision.")
.SetGroup("Movement");
property.GetExtraInfo().push_back("Dot shape");
property.GetExtraInfo().push_back("Bounding disk");
property.AddChoice("DotShape", "Dot shape");
property.AddChoice("BoundingDisk", "Bounding disk");
gd::PropertyFunctionGenerator::GenerateBehaviorGetterAndSetter(
project, extension, behavior, property, false);
@@ -157,7 +157,7 @@ TEST_CASE("PropertyFunctionGenerator", "[common]") {
gd::EventsFunction::ExpressionAndCondition);
REQUIRE(getter.GetExpressionType().GetName() == "stringWithSelector");
REQUIRE(getter.GetExpressionType().GetExtraInfo() ==
"[\"Dot shape\",\"Bounding disk\"]");
"[\"DotShape\",\"BoundingDisk\"]");
}
SECTION("Can generate functions for a boolean property in a behavior") {
@@ -386,8 +386,8 @@ TEST_CASE("PropertyFunctionGenerator", "[common]") {
.SetLabel("Dot shape")
.SetDescription("The shape is used for collision.")
.SetGroup("Movement");
property.GetExtraInfo().push_back("Dot shape");
property.GetExtraInfo().push_back("Bounding disk");
property.AddChoice("DotShape", "Dot shape");
property.AddChoice("BoundingDisk", "Bounding disk");
gd::PropertyFunctionGenerator::GenerateObjectGetterAndSetter(
project, extension, object, property);
@@ -404,7 +404,7 @@ TEST_CASE("PropertyFunctionGenerator", "[common]") {
gd::EventsFunction::ExpressionAndCondition);
REQUIRE(getter.GetExpressionType().GetName() == "stringWithSelector");
REQUIRE(getter.GetExpressionType().GetExtraInfo() ==
"[\"Dot shape\",\"Bounding disk\"]");
"[\"DotShape\",\"BoundingDisk\"]");
}
SECTION("Can generate functions for a boolean property in an object") {

View File

@@ -1174,6 +1174,7 @@ interface PropertyDescriptor {
[Const, Ref] DOMString GetDescription();
[Ref] PropertyDescriptor SetGroup([Const] DOMString label);
[Const, Ref] DOMString GetGroup();
[Ref] PropertyDescriptor ClearChoices();
[Ref] PropertyDescriptor AddChoice([Const] DOMString value, [Const] DOMString label);
[Const, Ref] VectorPropertyDescriptorChoice GetChoices();
[Ref] PropertyDescriptor AddExtraInfo([Const] DOMString type);

View File

@@ -971,6 +971,7 @@ export class PropertyDescriptor extends EmscriptenObject {
getDescription(): string;
setGroup(label: string): PropertyDescriptor;
getGroup(): string;
clearChoices(): PropertyDescriptor;
addChoice(value: string, label: string): PropertyDescriptor;
getChoices(): VectorPropertyDescriptorChoice;
addExtraInfo(type: string): PropertyDescriptor;

View File

@@ -11,6 +11,7 @@ declare class gdPropertyDescriptor {
getDescription(): string;
setGroup(label: string): gdPropertyDescriptor;
getGroup(): string;
clearChoices(): gdPropertyDescriptor;
addChoice(value: string, label: string): gdPropertyDescriptor;
getChoices(): gdVectorPropertyDescriptorChoice;
addExtraInfo(type: string): gdPropertyDescriptor;

View File

@@ -0,0 +1,102 @@
// @flow
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import * as React from 'react';
import {
ResponsiveLineStackLayout,
LineStackLayout,
ColumnStackLayout,
} from '../UI/Layout';
import { Line } from '../UI/Grid';
import SemiControlledTextField from '../UI/SemiControlledTextField';
import { IconButton } from '@material-ui/core';
import RaisedButton from '../UI/RaisedButton';
import Add from '../UI/CustomSvgIcons/Add';
import Trash from '../UI/CustomSvgIcons/Trash';
export type Choice = {|
value: string,
label: string,
|};
type ChoicesEditorProps = {|
choices: Array<Choice>,
setChoices: (Array<Choice>) => void,
disabled?: boolean,
hideLabels?: boolean,
|};
const ChoicesEditor = ({
choices,
setChoices,
disabled,
hideLabels,
}: ChoicesEditorProps) => {
const updateChoices = () => setChoices(choices);
return (
<ResponsiveLineStackLayout>
<ColumnStackLayout justifyContent="flex-end" expand noMargin>
{choices.map((item, index) => (
<LineStackLayout
key={index}
justifyContent="flex-end"
expand
alignItems="center"
noMargin
>
<SemiControlledTextField
disabled={disabled}
commitOnBlur
floatingLabelText={<Trans>Value</Trans>}
value={item.value}
onChange={text => {
choices[index].value = text;
updateChoices();
}}
fullWidth
/>
{!hideLabels && (
<SemiControlledTextField
disabled={disabled}
commitOnBlur
floatingLabelText={<Trans>Label displayed in editor</Trans>}
value={item.label}
onChange={text => {
choices[index].label = text;
updateChoices();
}}
fullWidth
/>
)}
<IconButton
disabled={disabled}
tooltip={t`Delete option`}
onClick={() => {
choices.splice(index, 1);
updateChoices();
}}
>
<Trash />
</IconButton>
</LineStackLayout>
))}
<Line justifyContent="flex-end" expand>
<RaisedButton
disabled={disabled}
primary
onClick={() => {
choices.push({ value: 'New Option', label: '' });
updateChoices();
}}
label={<Trans>Add a new option</Trans>}
icon={<Add />}
/>
</Line>
</ColumnStackLayout>
</ResponsiveLineStackLayout>
);
};
export default ChoicesEditor;

View File

@@ -15,7 +15,7 @@ import ElementWithMenu from '../UI/Menu/ElementWithMenu';
import SemiControlledTextField from '../UI/SemiControlledTextField';
import newNameGenerator from '../Utils/NewNameGenerator';
import { ResponsiveLineStackLayout, ColumnStackLayout } from '../UI/Layout';
import StringArrayEditor from '../StringArrayEditor';
import ChoicesEditor, { type Choice } from '../ChoicesEditor';
import ColorField from '../UI/ColorField';
import BehaviorTypeSelector from '../BehaviorTypeSelector';
import SemiControlledAutoComplete from '../UI/SemiControlledAutoComplete';
@@ -124,9 +124,13 @@ const getValidatedPropertyName = (
return safeAndUniqueNewName;
};
const getExtraInfoArray = (property: gdNamedPropertyDescriptor) => {
const extraInfoVector = property.getExtraInfo();
return extraInfoVector.toJSArray();
const getChoicesArray = (
property: gdNamedPropertyDescriptor
): Array<Choice> => {
return mapVector(property.getChoices(), choice => ({
value: choice.getValue(),
label: choice.getLabel(),
}));
};
export default function EventsBasedBehaviorPropertiesEditor({
@@ -397,17 +401,18 @@ export default function EventsBasedBehaviorPropertiesEditor({
[properties, forceUpdate, onPropertiesUpdated]
);
const setChoiceExtraInfo = React.useCallback(
const setChoices = React.useCallback(
(property: gdNamedPropertyDescriptor) => {
return (newExtraInfo: Array<string>) => {
const defaultValueIndex = getExtraInfoArray(property).indexOf(
property.getValue()
return (choices: Array<Choice>) => {
property.clearChoices();
for (const choice of choices) {
property.addChoice(choice.value, choice.label);
}
property.setValue(
getChoicesArray(property).includes(property.getValue())
? property.getValue()
: ''
);
const vectorString = new gd.VectorString();
newExtraInfo.forEach(item => vectorString.push_back(item));
property.setExtraInfo(vectorString);
vectorString.delete();
property.setValue(newExtraInfo[defaultValueIndex] || '');
forceUpdate();
};
},
@@ -954,12 +959,12 @@ export default function EventsBasedBehaviorPropertiesEditor({
}}
fullWidth
>
{getExtraInfoArray(property).map(
{getChoicesArray(property).map(
(choice, index) => (
<SelectOption
key={index}
value={choice}
label={choice}
value={choice.value}
label={choice.value}
/>
)
)}
@@ -967,11 +972,9 @@ export default function EventsBasedBehaviorPropertiesEditor({
)}
</ResponsiveLineStackLayout>
{property.getType() === 'Choice' && (
<StringArrayEditor
extraInfo={getExtraInfoArray(property)}
setExtraInfo={setChoiceExtraInfo(
property
)}
<ChoicesEditor
choices={getChoicesArray(property)}
setChoices={setChoices(property)}
/>
)}
<ResponsiveLineStackLayout noMargin>

View File

@@ -15,7 +15,7 @@ import ElementWithMenu from '../UI/Menu/ElementWithMenu';
import SemiControlledTextField from '../UI/SemiControlledTextField';
import newNameGenerator from '../Utils/NewNameGenerator';
import { ResponsiveLineStackLayout, ColumnStackLayout } from '../UI/Layout';
import StringArrayEditor from '../StringArrayEditor';
import ChoicesEditor, { type Choice } from '../ChoicesEditor';
import ColorField from '../UI/ColorField';
import SemiControlledAutoComplete from '../UI/SemiControlledAutoComplete';
import ScrollView, { type ScrollViewInterface } from '../UI/ScrollView';
@@ -119,9 +119,13 @@ const getValidatedPropertyName = (
return safeAndUniqueNewName;
};
const getExtraInfoArray = (property: gdNamedPropertyDescriptor) => {
const extraInfoVector = property.getExtraInfo();
return extraInfoVector.toJSArray();
const getChoicesArray = (
property: gdNamedPropertyDescriptor
): Array<Choice> => {
return mapVector(property.getChoices(), choice => ({
value: choice.getValue(),
label: choice.getLabel(),
}));
};
export default function EventsBasedObjectPropertiesEditor({
@@ -404,17 +408,18 @@ export default function EventsBasedObjectPropertiesEditor({
[eventsBasedObject, forceUpdate, onPropertiesUpdated]
);
const setChoiceExtraInfo = React.useCallback(
const setChoices = React.useCallback(
(property: gdNamedPropertyDescriptor) => {
return (newExtraInfo: Array<string>) => {
const defaultValueIndex = getExtraInfoArray(property).indexOf(
property.getValue()
return (choices: Array<Choice>) => {
property.clearChoices();
for (const choice of choices) {
property.addChoice(choice.value, choice.label);
}
property.setValue(
getChoicesArray(property).includes(property.getValue())
? property.getValue()
: ''
);
const vectorString = new gd.VectorString();
newExtraInfo.forEach(item => vectorString.push_back(item));
property.setExtraInfo(vectorString);
vectorString.delete();
property.setValue(newExtraInfo[defaultValueIndex] || '');
forceUpdate();
};
},
@@ -877,12 +882,12 @@ export default function EventsBasedObjectPropertiesEditor({
}}
fullWidth
>
{getExtraInfoArray(property).map(
{getChoicesArray(property).map(
(choice, index) => (
<SelectOption
key={index}
value={choice}
label={choice}
value={choice.value}
label={choice.label}
/>
)
)}
@@ -906,11 +911,9 @@ export default function EventsBasedObjectPropertiesEditor({
)}
</ResponsiveLineStackLayout>
{property.getType() === 'Choice' && (
<StringArrayEditor
extraInfo={getExtraInfoArray(property)}
setExtraInfo={setChoiceExtraInfo(
property
)}
<ChoicesEditor
choices={getChoicesArray(property)}
setChoices={setChoices(property)}
/>
)}
<ResponsiveLineStackLayout noMargin>

View File

@@ -9,7 +9,7 @@ import SemiControlledTextField from '../../UI/SemiControlledTextField';
import ObjectTypeSelector from '../../ObjectTypeSelector';
import BehaviorTypeSelector from '../../BehaviorTypeSelector';
import { ColumnStackLayout, ResponsiveLineStackLayout } from '../../UI/Layout';
import StringArrayEditor from '../../StringArrayEditor';
import ChoicesEditor, { type Choice } from '../../ChoicesEditor';
import useForceUpdate from '../../Utils/UseForceUpdate';
import ResourceTypeSelectField from './ResourceTypeSelectField';
@@ -295,11 +295,18 @@ export default function ValueTypeEditor({
)}
</ResponsiveLineStackLayout>
{valueTypeMetadata.getName() === 'stringWithSelector' && (
<StringArrayEditor
<ChoicesEditor
disabled={disabled}
extraInfo={getExtraInfoArray(valueTypeMetadata)}
setExtraInfo={(newExtraInfo: Array<string>) => {
valueTypeMetadata.setExtraInfo(JSON.stringify(newExtraInfo));
choices={getExtraInfoArray(valueTypeMetadata).map(value => ({
value,
label: '',
}))}
hideLabels
setChoices={(choices: Array<Choice>) => {
// TODO Handle labels (and search "choice label")
valueTypeMetadata.setExtraInfo(
JSON.stringify(choices.map(choice => choice.value))
);
forceUpdate();
onTypeUpdated();
}}

View File

@@ -1,77 +0,0 @@
// @flow
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import * as React from 'react';
import { ResponsiveLineStackLayout } from '../UI/Layout';
import { Line, Column } from '../UI/Grid';
import SemiControlledTextField from '../UI/SemiControlledTextField';
import { IconButton } from '@material-ui/core';
import RaisedButton from '../UI/RaisedButton';
import Add from '../UI/CustomSvgIcons/Add';
import Trash from '../UI/CustomSvgIcons/Trash';
type StringArrayEditorProps = {|
extraInfo: Array<string>,
setExtraInfo: (Array<string>) => void,
disabled?: boolean,
|};
const StringArrayEditor = ({
extraInfo,
setExtraInfo,
disabled,
}: StringArrayEditorProps) => {
const updateExtraInfo = () => setExtraInfo(extraInfo);
return (
<ResponsiveLineStackLayout>
<Column justifyContent="flex-end" expand noMargin>
{extraInfo.map((item, index) => (
<Line
key={index}
justifyContent="flex-end"
expand
alignItems="center"
noMargin
>
<SemiControlledTextField
disabled={disabled}
commitOnBlur
value={item}
onChange={text => {
extraInfo[index] = text;
updateExtraInfo();
}}
fullWidth
/>
<IconButton
disabled={disabled}
tooltip={t`Delete option`}
onClick={() => {
extraInfo.splice(index, 1);
updateExtraInfo();
}}
>
<Trash />
</IconButton>
</Line>
))}
<Line justifyContent="flex-end" expand>
<RaisedButton
disabled={disabled}
primary
onClick={() => {
extraInfo.push('New Option');
updateExtraInfo();
}}
label={<Trans>Add a new option</Trans>}
icon={<Add />}
/>
</Line>
</Column>
</ResponsiveLineStackLayout>
);
};
export default StringArrayEditor;