diff --git a/newIDE/app/src/ObjectsRendering/Renderers/CustomObjectLayoutingModel.js b/newIDE/app/src/ObjectsRendering/Renderers/CustomObjectLayoutingModel.js new file mode 100644 index 000000000000..ec86c463351d --- /dev/null +++ b/newIDE/app/src/ObjectsRendering/Renderers/CustomObjectLayoutingModel.js @@ -0,0 +1,557 @@ +// @flow + +// - The term "object" is used in comments about the layout declaration because +// the layout is done with one instance per object-child and the object name +// is used to reference these instances. +// - The term "instance" is used for the layout calculus because it's actually +// instances that are in the scene editor. + +type AxisLayout = { + /** + * The origin of the anchor on the object to place + * as a factor of the current object size + * (0 for left or top, 1 for right or bottom). + */ + anchorOrigin?: number, + /** + * The target of the anchor on the referential object + * as a factor of the targeted object size + * (0 for left or top, 1 for right or bottom). + */ + anchorTarget?: number, + /** + * The object name to take as referential. + */ + anchorTargetObject?: string, + /** + * A displacement to add on the anchored object. + */ + anchorDelta?: number, + /** + * The left or top margin in pixels. + */ + minSideAbsoluteMargin?: number, + /** + * The right or bottom margin in pixels. + */ + maxSideAbsoluteMargin?: number, + /** + * The left or top margin as a factor of the parent size. + */ + minSideProportionalMargin?: number, + /** + * The right or bottom margin as a factor of the parent size. + */ + maxSideProportionalMargin?: number, +}; + +/** + * Layout description that allows to position the child-objects + * to follow the size of the parent. + */ +export type ChildLayout = { + /** + * Some child-object are optional or only displayed according to the parent state. + * For example, for buttons there is a background for each state. + */ + isShown: boolean, + horizontalLayout: AxisLayout, + verticalLayout: AxisLayout, +}; + +/** + * The keywords to find in the properties to build the ChildLayout. + */ +const layoutFields = [ + 'Show', + 'LeftPadding', + 'TopPadding', + 'RightPadding', + 'BottomPadding', + 'HorizontalAnchorOrigin', + 'HorizontalAnchorTarget', + 'VerticalAnchorOrigin', + 'VerticalAnchorTarget', + 'AnchorOrigin', + 'AnchorTarget', + 'AnchorDeltaX', + 'AnchorDeltaY', +]; + +const getHorizontalAnchorValue = ( + anchorName: string, + properties: ?gdMapStringPropertyDescriptor +): ?number => { + const horizontalAnchorName = (anchorName.includes('-') + ? anchorName.split('-')[1] + : anchorName + ).toLowerCase(); + return horizontalAnchorName === 'left' + ? 0 + : horizontalAnchorName === 'right' + ? 1 + : horizontalAnchorName === 'center' + ? 0.5 + : // Reference to another property to allow to expose a Choice property. + properties && properties.has(anchorName) + ? getHorizontalAnchorValue(properties.get(anchorName).getValue(), null) + : null; +}; + +const getVerticalAnchorValue = ( + anchorName: string, + properties: ?gdMapStringPropertyDescriptor +): ?number => { + const verticalAnchorName = (anchorName.includes('-') + ? anchorName.split('-')[0] + : anchorName + ).toLowerCase(); + return verticalAnchorName === 'top' + ? 0 + : verticalAnchorName === 'bottom' + ? 1 + : verticalAnchorName === 'center' + ? 0.5 + : // Reference to another property to allow to expose a Choice property. + properties && properties.has(anchorName) + ? getVerticalAnchorValue(properties.get(anchorName).getValue(), null) + : null; +}; + +/** + * Origin anchors can have smart value to only expose the target anchor property + * and fill the origin anchor property accordingly. + */ +const getHorizontalOriginAnchorValue = ( + anchorName: string, + properties: gdMapStringPropertyDescriptor, + targetAnchorValue: ?number +): ?number => { + const horizontalAnchorName = (anchorName.includes('-') + ? anchorName.split('-')[1] + : anchorName + ).toLowerCase(); + return horizontalAnchorName === 'same' + ? targetAnchorValue + : horizontalAnchorName === 'opposite' && targetAnchorValue != null + ? 1 - targetAnchorValue + : getHorizontalAnchorValue(horizontalAnchorName, properties); +}; + +/** + * Origin anchors can have smart value to only expose the target anchor property + * and fill the origin anchor property accordingly. + */ +const getVerticalOriginAnchorValue = ( + anchorName: string, + properties: gdMapStringPropertyDescriptor, + targetAnchorValue: ?number +): ?number => { + const verticalAnchorName = (anchorName.includes('-') + ? anchorName.split('-')[0] + : anchorName + ).toLowerCase(); + return verticalAnchorName === 'same' + ? targetAnchorValue + : verticalAnchorName === 'opposite' && targetAnchorValue != null + ? 1 - targetAnchorValue + : getVerticalAnchorValue(verticalAnchorName, properties); +}; + +export interface PropertiesContainer { + getProperties(): gdMapStringPropertyDescriptor; +} + +/** + * Build the layouts description from the custom object properties. + */ +export const getLayouts = ( + eventBasedObject: gdEventsBasedObject, + customObjectConfiguration: PropertiesContainer +): Map => { + const layouts: Map = new Map(); + const properties = eventBasedObject.getPropertyDescriptors(); + const instanceProperties = customObjectConfiguration.getProperties(); + + for ( + let propertyIndex = 0; + propertyIndex < properties.getCount(); + propertyIndex++ + ) { + const property = properties.getAt(propertyIndex); + + /** + * The list of child-object where the layout is applied + */ + const childNames = property.getExtraInfo(); + if (!childNames) { + continue; + } + + // The property types should never be checked because we may introduce + // new types to make the layout configuration easier. + const name = property.getName(); + const propertyValueString = instanceProperties.get(name).getValue(); + const propertyValueNumber = Number.parseFloat(propertyValueString) || 0; + const layoutField = layoutFields.find(field => name.includes(field)); + + // AnchorTarget extraInfo is not the list of child-object where the layout is applied + // but the child that is the target of the anchor. + // The extraInfos from the AnchorOrigin is used to get this child-object list + let targetObjectName = ''; + let horizontalAnchorTarget: ?number = null; + let verticalAnchorTarget: ?number = null; + if ( + layoutField === 'HorizontalAnchorOrigin' || + layoutField === 'VerticalAnchorOrigin' || + layoutField === 'AnchorOrigin' + ) { + const targetPropertyName = name.replace('AnchorOrigin', 'AnchorTarget'); + if (properties.has(targetPropertyName)) { + const targetProperty = properties.get(targetPropertyName); + targetObjectName = + targetProperty.getExtraInfo().size() > 0 + ? targetProperty.getExtraInfo().at(0) + : ''; + const anchorTargetStringValue = instanceProperties + .get(targetPropertyName) + .getValue(); + const anchorTargetValueNumber = + Number.parseFloat(anchorTargetStringValue) || 0; + if ( + layoutField === 'HorizontalAnchorOrigin' || + layoutField === 'AnchorOrigin' + ) { + horizontalAnchorTarget = + getHorizontalAnchorValue( + anchorTargetStringValue, + instanceProperties + ) || anchorTargetValueNumber; + } + if ( + layoutField === 'VerticalAnchorOrigin' || + layoutField === 'AnchorOrigin' + ) { + verticalAnchorTarget = + getVerticalAnchorValue( + anchorTargetStringValue, + instanceProperties + ) || anchorTargetValueNumber; + } + } + } + + for (let childIndex = 0; childIndex < childNames.size(); childIndex++) { + const childName = childNames.at(childIndex); + let layout = layouts.get(childName); + if (!layout) { + layout = { + isShown: true, + horizontalLayout: {}, + verticalLayout: {}, + }; + layouts.set(childName, layout); + } + if (layoutField === 'Show') { + if (propertyValueString !== 'true') { + layout.isShown = false; + } + } else if (layoutField === 'LeftPadding') { + layout.horizontalLayout.minSideAbsoluteMargin = propertyValueNumber; + } else if (layoutField === 'RightPadding') { + layout.horizontalLayout.maxSideAbsoluteMargin = propertyValueNumber; + } else if (layoutField === 'TopPadding') { + layout.verticalLayout.minSideAbsoluteMargin = propertyValueNumber; + } else if (layoutField === 'BottomPadding') { + layout.verticalLayout.maxSideAbsoluteMargin = propertyValueNumber; + } else if (layoutField === 'AnchorDeltaX') { + layout.horizontalLayout.anchorDelta = propertyValueNumber; + } else if (layoutField === 'AnchorDeltaY') { + layout.verticalLayout.anchorDelta = propertyValueNumber; + } else { + if ( + layoutField === 'HorizontalAnchorOrigin' || + layoutField === 'AnchorOrigin' + ) { + const anchorOrigin = + getHorizontalOriginAnchorValue( + propertyValueString, + instanceProperties, + horizontalAnchorTarget + ) || propertyValueNumber; + if (anchorOrigin !== null) { + layout.horizontalLayout.anchorOrigin = anchorOrigin; + } + if (horizontalAnchorTarget !== null) { + layout.horizontalLayout.anchorTarget = horizontalAnchorTarget; + } + layout.horizontalLayout.anchorTargetObject = targetObjectName; + } + if ( + layoutField === 'VerticalAnchorOrigin' || + layoutField === 'AnchorOrigin' + ) { + const anchorOrigin = + getVerticalOriginAnchorValue( + propertyValueString, + instanceProperties, + horizontalAnchorTarget + ) || propertyValueNumber; + if (anchorOrigin !== null) { + layout.verticalLayout.anchorOrigin = anchorOrigin; + } + if (verticalAnchorTarget !== null) { + layout.verticalLayout.anchorTarget = verticalAnchorTarget; + } + layout.verticalLayout.anchorTargetObject = targetObjectName; + } + } + } + } + return layouts; +}; + +// TODO EBO Make an event-based object instance editor (like the one for the scene) +// and use real instances instead of this. +export class ChildInstance { + x: number; + y: number; + _hasCustomSize: boolean; + _customWidth: number; + _customHeight: number; + + constructor() { + this.x = 0; + this.y = 0; + this._customWidth = 0; + this._customHeight = 0; + this._hasCustomSize = false; + } + + getX() { + return this.x; + } + + getY() { + return this.y; + } + + getAngle() { + return 0; + } + + setObjectName(name: string) {} + + getObjectName() { + return ''; + } + + setX(x: number) {} + + setY(y: number) {} + + setAngle(angle: number) {} + + isLocked() { + return false; + } + + setLocked(lock: boolean) {} + + isSealed() { + return false; + } + + setSealed(seal: boolean) {} + + getZOrder() { + return 0; + } + + setZOrder(zOrder: number) {} + + getLayer() { + return ''; + } + + setLayer(layer: string) {} + + setHasCustomSize(enable: boolean) { + this._hasCustomSize = enable; + } + + hasCustomSize() { + return this._hasCustomSize; + } + + setCustomWidth(width: number) { + this._customWidth = width; + this._hasCustomSize = true; + } + + getCustomWidth() { + return this._customWidth; + } + + setCustomHeight(height: number) { + this._customHeight = height; + this._hasCustomSize = true; + } + + getCustomHeight() { + return this._customHeight; + } + + resetPersistentUuid() { + return this; + } + + updateCustomProperty( + name: string, + value: string, + project: gdProject, + layout: gdLayout + ) {} + + getCustomProperties(project: gdProject, layout: gdLayout) { + return null; + } + + getRawDoubleProperty(name: string) { + return 0; + } + + getRawStringProperty(name: string) { + return ''; + } + + setRawDoubleProperty(name: string, value: number) {} + + setRawStringProperty(name: string, value: string) {} + + getVariables() { + return []; + } + + serializeTo(element: gdSerializerElement) {} + + unserializeFrom(element: gdSerializerElement) {} +} + +export type InitialInstanceDimension = { + hasCustomSize(): boolean, + getCustomWidth(): number, + getCustomHeight(): number, + getX(): number, + getY(): number, +}; + +export interface ChildRenderedInstance { + +_instance: InitialInstanceDimension; + _pixiObject: { height: number }; + getDefaultWidth(): number; + getDefaultHeight(): number; + update(): void; +} + +export interface LayoutedParent< + CovariantChildRenderedInstance: ChildRenderedInstance +> { + childrenInstances: ChildInstance[]; + childrenLayouts: ChildLayout[]; + childrenRenderedInstances: Array; + childrenRenderedInstanceByNames: Map; + getWidth(): number; + getHeight(): number; +} + +export const applyChildLayouts = ( + parent: LayoutedParent +) => { + const width = parent.getWidth(); + const height = parent.getHeight(); + + for ( + let index = 0; + index < parent.childrenRenderedInstances.length; + index++ + ) { + const renderedInstance = parent.childrenRenderedInstances[index]; + const childInstance = parent.childrenInstances[index]; + const childLayout = parent.childrenLayouts[index]; + + if (childLayout.horizontalLayout.anchorOrigin == null) { + const childMinX = + childLayout.horizontalLayout.minSideAbsoluteMargin || + (childLayout.horizontalLayout.minSideProportionalMargin || 0) * width; + const childMaxX = + width - + (childLayout.horizontalLayout.maxSideAbsoluteMargin || + (childLayout.horizontalLayout.maxSideProportionalMargin || 0) * + width); + + childInstance.x = childMinX; + childInstance.setCustomWidth(childMaxX - childMinX); + } else { + const anchorOrigin = childLayout.horizontalLayout.anchorOrigin || 0; + const anchorTarget = childLayout.horizontalLayout.anchorTarget || 0; + + const targetRenderedInstance = + parent.childrenRenderedInstanceByNames.get( + childLayout.horizontalLayout.anchorTargetObject || '' + ) || parent.childrenRenderedInstances[0]; + const targetInstance = targetRenderedInstance._instance; + const targetInstanceWidth = targetInstance.hasCustomSize() + ? targetInstance.getCustomWidth() + : targetRenderedInstance.getDefaultWidth(); + + childInstance.x = + targetInstance.getX() + + (childLayout.horizontalLayout.anchorDelta || 0) + + anchorTarget * targetInstanceWidth - + anchorOrigin * renderedInstance.getDefaultWidth(); + childInstance.setCustomWidth(renderedInstance.getDefaultWidth()); + } + + if (childLayout.verticalLayout.anchorOrigin == null) { + const childMinY = + childLayout.verticalLayout.minSideAbsoluteMargin || + (childLayout.verticalLayout.minSideProportionalMargin || 0) * height; + const childMaxY = + height - + (childLayout.verticalLayout.maxSideAbsoluteMargin || + (childLayout.verticalLayout.maxSideProportionalMargin || 0) * height); + + childInstance.y = childMinY; + const expectedHeight = childMaxY - childMinY; + childInstance.setCustomHeight(childMaxY - childMinY); + + renderedInstance.update(); + // This ensure objects are centered if their dimensions changed from the + // custom ones (preferred ones). + // For instance, text object dimensions change according to how the text is wrapped. + childInstance.y += + (expectedHeight - renderedInstance._pixiObject.height) / 2; + } else { + const anchorOrigin = childLayout.verticalLayout.anchorOrigin || 0; + const anchorTarget = childLayout.verticalLayout.anchorTarget || 0; + + const targetRenderedInstance = + parent.childrenRenderedInstanceByNames.get( + childLayout.horizontalLayout.anchorTargetObject || '' + ) || parent.childrenRenderedInstances[0]; + const targetInstance = targetRenderedInstance._instance; + const targetInstanceHeight = targetInstance.hasCustomSize() + ? targetInstance.getCustomHeight() + : targetRenderedInstance.getDefaultHeight(); + + childInstance.y = + targetInstance.getY() + + (childLayout.verticalLayout.anchorDelta || 0) + + anchorTarget * targetInstanceHeight - + anchorOrigin * renderedInstance.getDefaultHeight(); + childInstance.setCustomHeight(renderedInstance.getDefaultHeight()); + } + renderedInstance.update(); + } +}; diff --git a/newIDE/app/src/ObjectsRendering/Renderers/CustomObjectLayoutingModel.spec.js b/newIDE/app/src/ObjectsRendering/Renderers/CustomObjectLayoutingModel.spec.js new file mode 100644 index 000000000000..ed17cf265d37 --- /dev/null +++ b/newIDE/app/src/ObjectsRendering/Renderers/CustomObjectLayoutingModel.spec.js @@ -0,0 +1,462 @@ +// @flow +import { + getLayouts, + applyChildLayouts, + type ChildLayout, + ChildInstance, + LayoutedParent, + ChildRenderedInstance, + PropertiesContainer, +} from './CustomObjectLayoutingModel'; +import { mapFor } from '../../Utils/MapFor'; + +const gd: libGDevelop = global.gd; + +describe('getLayouts', () => { + it('can fill the parent with a child', () => { + const eventBasedObject = createEventBasedObject([]); + const customObjectConfiguration = createCustomObjectConfiguration( + eventBasedObject, + [] + ); + const layouts = getLayouts(eventBasedObject, customObjectConfiguration); + // A default layout will be set by RenderedCustomObjectInstance constructor + // which is not covered by tests. + expect(layouts.has('Background')).toBe(false); + }); + + it('can fill the parent with an hidden child', () => { + const eventBasedObject = createEventBasedObject([ + { name: 'ShowBackground', extraInfos: ['Background'] }, + ]); + const customObjectConfiguration = createCustomObjectConfiguration( + eventBasedObject, + [{ name: 'ShowBackground', value: 'false' }] + ); + const layouts = getLayouts(eventBasedObject, customObjectConfiguration); + + expect(layouts.get('Background')).toStrictEqual({ + isShown: false, + horizontalLayout: {}, + verticalLayout: {}, + }); + }); + + it('can fill the parent with a child with margins', () => { + const eventBasedObject = createEventBasedObject([ + { name: 'BarLeftPadding', extraInfos: ['PanelBar'] }, + { name: 'BarRightPadding', extraInfos: ['PanelBar'] }, + { name: 'BarTopPadding', extraInfos: ['PanelBar'] }, + { name: 'BarBottomPadding', extraInfos: ['PanelBar'] }, + ]); + const customObjectConfiguration = createCustomObjectConfiguration( + eventBasedObject, + [ + { name: 'BarLeftPadding', value: '10' }, + { name: 'BarRightPadding', value: '20' }, + { name: 'BarTopPadding', value: '30' }, + { name: 'BarBottomPadding', value: '40' }, + ] + ); + const layouts = getLayouts(eventBasedObject, customObjectConfiguration); + + expect(layouts.get('PanelBar')).toStrictEqual({ + isShown: true, + horizontalLayout: { + minSideAbsoluteMargin: 10, + maxSideAbsoluteMargin: 20, + }, + verticalLayout: { minSideAbsoluteMargin: 30, maxSideAbsoluteMargin: 40 }, + }); + }); + + it('can fill the parent width with margins while keeping default height', () => { + const eventBasedObject = createEventBasedObject([ + { name: 'BarLeftPadding', extraInfos: ['TiledBar'] }, + { name: 'BarRightPadding', extraInfos: ['TiledBar'] }, + // Private properties + { + name: 'BarVerticalAnchorOrigin', + extraInfos: ['TiledBar'], + value: 'Center', + }, + { + name: 'BarVerticalAnchorTarget', + extraInfos: [], + value: 'Center', + }, + ]); + const customObjectConfiguration = createCustomObjectConfiguration( + eventBasedObject, + [ + { name: 'BarLeftPadding', value: '10' }, + { name: 'BarRightPadding', value: '20' }, + ] + ); + const layouts = getLayouts(eventBasedObject, customObjectConfiguration); + + expect(layouts.get('TiledBar')).toStrictEqual({ + isShown: true, + horizontalLayout: { + minSideAbsoluteMargin: 10, + maxSideAbsoluteMargin: 20, + }, + // The anchorTargetObject default on the object in the background. + verticalLayout: { + anchorOrigin: 0.5, + anchorTarget: 0.5, + anchorTargetObject: '', + }, + }); + }); + + it('can anchor a chid to another child', () => { + const eventBasedObject = createEventBasedObject([ + // Private properties + { + name: 'ThumbAnchorOrigin', + extraInfos: ['Thumb'], + value: 'Center-center', + }, + { + name: 'ThumbAnchorTarget', + extraInfos: ['PanelBar'], + value: 'Center-right', + }, + ]); + const customObjectConfiguration = createCustomObjectConfiguration( + eventBasedObject, + [] + ); + const layouts = getLayouts(eventBasedObject, customObjectConfiguration); + + expect(layouts.get('Thumb')).toStrictEqual({ + isShown: true, + horizontalLayout: { + anchorOrigin: 0.5, + anchorTarget: 1, + anchorTargetObject: 'PanelBar', + }, + verticalLayout: { + anchorOrigin: 0.5, + anchorTarget: 0.5, + anchorTargetObject: 'PanelBar', + }, + }); + }); +}); + +describe('applyChildLayouts', () => { + it('can fill the parent with a child', () => { + const parent = new MockedParent(200, 100); + // This is the default layout set by RenderedCustomObjectInstance constructor + // which is not covered by tests. + const background = parent.addChild('Background', { + isShown: true, + horizontalLayout: {}, + verticalLayout: {}, + }); + + applyChildLayouts(parent); + + expect(background.getX()).toBe(0); + expect(background.getY()).toBe(0); + expect(background.hasCustomSize()).toBe(true); + expect(background.getCustomWidth()).toBe(200); + expect(background.getCustomHeight()).toBe(100); + }); + + it('can fill the parent with an hidden child', () => { + const parent = new MockedParent(200, 100); + // The child is hidden by RenderedCustomObjectInstance constructor + // which is not covered by tests. + // The constructor removes the child from its Pixi container. + // This test actually doesn't cover more than the previous one. + const background = parent.addChild('Background', { + isShown: false, + horizontalLayout: {}, + verticalLayout: {}, + }); + + applyChildLayouts(parent); + + expect(background.getX()).toBe(0); + expect(background.getY()).toBe(0); + expect(background.hasCustomSize()).toBe(true); + expect(background.getCustomWidth()).toBe(200); + expect(background.getCustomHeight()).toBe(100); + }); + + it('can fill the parent with a child with margins', () => { + const parent = new MockedParent(200, 100); + const panelBar = parent.addChild('PanelBar', { + isShown: true, + horizontalLayout: { + minSideAbsoluteMargin: 10, + maxSideAbsoluteMargin: 20, + }, + verticalLayout: { minSideAbsoluteMargin: 30, maxSideAbsoluteMargin: 40 }, + }); + + applyChildLayouts(parent); + + expect(panelBar.getX()).toBe(10); + expect(panelBar.getY()).toBe(30); + expect(panelBar.hasCustomSize()).toBe(true); + expect(panelBar.getCustomWidth()).toBe(200 - 10 - 20); + expect(panelBar.getCustomHeight()).toBe(100 - 30 - 40); + }); + + it('can fill the parent with a text child with margins', () => { + const parent = new MockedParent(200, 100); + const label = parent.addChild( + 'Label', + { + isShown: true, + horizontalLayout: { + minSideAbsoluteMargin: 10, + maxSideAbsoluteMargin: 20, + }, + verticalLayout: { + minSideAbsoluteMargin: 30, + maxSideAbsoluteMargin: 40, + }, + }, + { heightAfterUpdate: 20 } + ); + + applyChildLayouts(parent); + + expect(label.getX()).toBe(10); + expect(label.getY()).toBe(30 + (100 - 30 - 40 - 20) / 2); + expect(label.hasCustomSize()).toBe(true); + expect(label.getCustomWidth()).toBe(200 - 10 - 20); + }); + + it('can fill the parent width with margins while keeping default height', () => { + const parent = new MockedParent(200, 100); + parent.addChild('Background', { + isShown: true, + horizontalLayout: {}, + verticalLayout: {}, + }); + const tiledBar = parent.addChild( + 'TiledBar', + { + isShown: true, + horizontalLayout: { + minSideAbsoluteMargin: 10, + maxSideAbsoluteMargin: 20, + }, + verticalLayout: { anchorOrigin: 0.5, anchorTarget: 0.5 }, + }, + { defaultWidth: 30, defaultHeight: 40 } + ); + + applyChildLayouts(parent); + + expect(tiledBar.getX()).toBe(10); + expect(tiledBar.getY()).toBe((100 - 40) / 2); + expect(tiledBar.hasCustomSize()).toBe(true); + expect(tiledBar.getCustomWidth()).toBe(200 - 10 - 20); + expect(tiledBar.getCustomHeight()).toBe(40); + }); + + it('can anchor a chid to another child', () => { + const parent = new MockedParent(200, 100); + parent.addChild('Background', { + isShown: true, + horizontalLayout: {}, + verticalLayout: {}, + }); + parent.addChild('PanelBar', { + isShown: true, + horizontalLayout: { + minSideAbsoluteMargin: 10, + maxSideAbsoluteMargin: 20, + }, + verticalLayout: { minSideAbsoluteMargin: 30, maxSideAbsoluteMargin: 40 }, + }); + const thumb = parent.addChild( + 'Thumb', + { + isShown: true, + horizontalLayout: { + anchorOrigin: 0.5, + anchorTarget: 1, + anchorTargetObject: 'PanelBar', + }, + verticalLayout: { + anchorOrigin: 0.5, + anchorTarget: 0.5, + anchorTargetObject: 'PanelBar', + }, + }, + { defaultWidth: 50, defaultHeight: 60 } + ); + + applyChildLayouts(parent); + + expect(thumb.getX()).toBe(200 - 20 - 50 / 2); + expect(thumb.getY()).toBe(30 + (100 - 30 - 40) / 2 - 60 / 2); + expect(thumb.hasCustomSize()).toBe(true); + expect(thumb.getCustomWidth()).toBe(50); + expect(thumb.getCustomHeight()).toBe(60); + }); +}); + +type EventBasedObjectProperty = { + name: string, + value?: string, + extraInfos: string[], +}; + +const createEventBasedObject = ( + propertiesData: EventBasedObjectProperty[] +): gdEventsBasedObject => { + const eventBasedObject = new gd.EventsBasedObject(); + const properties = eventBasedObject.getPropertyDescriptors(); + propertiesData.forEach((propertyData, index) => { + const property = properties.insertNew(propertyData.name, index); + if (propertyData.value) { + property.setValue(propertyData.value); + } + propertyData.extraInfos.forEach(extraInfo => + property.addExtraInfo(extraInfo) + ); + }); + return eventBasedObject; +}; + +class MockedCustomObjectConfiguration implements PropertiesContainer { + mapStringPropertyDescriptor: gdMapStringPropertyDescriptor; + + constructor() { + this.mapStringPropertyDescriptor = new gd.MapStringPropertyDescriptor(); + } + + getProperties(): gdMapStringPropertyDescriptor { + return this.mapStringPropertyDescriptor; + } +} + +type CustomObjectPropertyValue = { + name: string, + value: string, +}; + +const createCustomObjectConfiguration = ( + eventBasedObject: gdEventsBasedObject, + propertiesData: CustomObjectPropertyValue[] +): MockedCustomObjectConfiguration => { + const customObjectConfiguration = new MockedCustomObjectConfiguration(); + + // Add default values from the event-based object. + const instanceProperties = customObjectConfiguration.getProperties(); + const properties = eventBasedObject.getPropertyDescriptors(); + mapFor(0, properties.size(), index => { + const property = properties.getAt(index); + instanceProperties + .getOrCreate(property.getName()) + .setValue(property.getValue()); + }); + + // Add values set by extension users. + propertiesData.forEach((propertyData, index) => + instanceProperties + .getOrCreate(propertyData.name) + .setValue(propertyData.value) + ); + return customObjectConfiguration; +}; + +class MockedChildRenderedInstance implements ChildRenderedInstance { + _instance: ChildInstance; + _pixiObject: { height: number }; + defaultWidth: number; + defaultHeight: number; + heightAfterUpdate: ?number; + + constructor( + childInstance: ChildInstance, + defaultWidth: number, + defaultHeight: number, + heightAfterUpdate: ?number + ) { + this._instance = childInstance; + this._pixiObject = { height: 0 }; + this.defaultWidth = defaultWidth; + this.defaultHeight = defaultHeight; + this.heightAfterUpdate = heightAfterUpdate; + } + + getDefaultWidth(): number { + return this.defaultWidth; + } + + getDefaultHeight(): number { + return this.defaultHeight; + } + + update(): void { + this._pixiObject.height = + this.heightAfterUpdate || + (this._instance.hasCustomSize() + ? this._instance.getCustomHeight() + : this.defaultHeight); + } +} + +class MockedParent implements LayoutedParent { + width: number; + height: number; + childrenInstances: ChildInstance[]; + childrenLayouts: ChildLayout[]; + childrenRenderedInstances: Array; + childrenRenderedInstanceByNames: Map; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + this.childrenInstances = []; + this.childrenLayouts = []; + this.childrenRenderedInstances = []; + this.childrenRenderedInstanceByNames = new Map< + string, + MockedChildRenderedInstance + >(); + } + + getWidth() { + return this.width; + } + + getHeight() { + return this.height; + } + + addChild( + name: string, + layout: ChildLayout, + size?: {| + defaultWidth?: number, + defaultHeight?: number, + heightAfterUpdate?: number, + |} + ) { + const childInstance = new ChildInstance(); + const childRenderedInstance = new MockedChildRenderedInstance( + childInstance, + size ? size.defaultWidth || 0 : 0, + size ? size.defaultHeight || 0 : 0, + size && size.heightAfterUpdate + ); + + this.childrenLayouts.push(layout); + this.childrenInstances.push(childInstance); + this.childrenRenderedInstances.push(childRenderedInstance); + this.childrenRenderedInstanceByNames.set(name, childRenderedInstance); + + return childInstance; + } +} diff --git a/newIDE/app/src/ObjectsRendering/Renderers/RenderedCustomObjectInstance.js b/newIDE/app/src/ObjectsRendering/Renderers/RenderedCustomObjectInstance.js index f63db6b4c950..982c3c29f71c 100644 --- a/newIDE/app/src/ObjectsRendering/Renderers/RenderedCustomObjectInstance.js +++ b/newIDE/app/src/ObjectsRendering/Renderers/RenderedCustomObjectInstance.js @@ -5,143 +5,26 @@ import ResourcesLoader from '../../ResourcesLoader'; import ObjectsRenderingService from '../ObjectsRenderingService'; import RenderedTextInstance from './RenderedTextInstance'; import { mapReverseFor } from '../../Utils/MapFor'; +import { + getLayouts, + applyChildLayouts, + ChildInstance, + type ChildLayout, + LayoutedParent, +} from './CustomObjectLayoutingModel'; import * as PIXI from 'pixi.js-legacy'; const gd: libGDevelop = global.gd; -// TODO EBO Make an event-based object instance editor (like the one for the scene) -// and use real instances instead of this. -class ChildInstance { - x: number; - y: number; - _hasCustomSize: boolean; - _customWidth: number; - _customHeight: number; - - constructor() { - this.x = 0; - this.y = 0; - this._customWidth = 0; - this._customHeight = 0; - this._hasCustomSize = false; - } - - getX() { - return this.x; - } - - getY() { - return this.y; - } - - getAngle() { - return 0; - } - - setObjectName(name: string) {} - - getObjectName() { - return ''; - } - - setX(x: number) {} - - setY(y: number) {} - - setAngle(angle: number) {} - - isLocked() { - return false; - } - - setLocked(lock: boolean) {} - - isSealed() { - return false; - } - - setSealed(seal: boolean) {} - - getZOrder() { - return 0; - } - - setZOrder(zOrder: number) {} - - getLayer() { - return ''; - } - - setLayer(layer: string) {} - - setHasCustomSize(enable: boolean) { - this._hasCustomSize = enable; - } - - hasCustomSize() { - return this._hasCustomSize; - } - - setCustomWidth(width: number) { - this._customWidth = width; - this._hasCustomSize = true; - } - - getCustomWidth() { - return this._customWidth; - } - - setCustomHeight(height: number) { - this._customHeight = height; - this._hasCustomSize = true; - } - - getCustomHeight() { - return this._customHeight; - } - - resetPersistentUuid() { - return this; - } - - updateCustomProperty( - name: string, - value: string, - project: gdProject, - layout: gdLayout - ) {} - - getCustomProperties(project: gdProject, layout: gdLayout) { - return null; - } - - getRawDoubleProperty(name: string) { - return 0; - } - - getRawStringProperty(name: string) { - return ''; - } - - setRawDoubleProperty(name: string, value: number) {} - - setRawStringProperty(name: string, value: string) {} - - getVariables() { - return []; - } - - serializeTo(element: gdSerializerElement) {} - - unserializeFrom(element: gdSerializerElement) {} -} - /** * Renderer for gd.CustomObject (the class is not exposed to newIDE) */ -export default class RenderedCustomObjectInstance extends RenderedInstance { +export default class RenderedCustomObjectInstance extends RenderedInstance + implements LayoutedParent { childrenInstances: ChildInstance[]; + childrenLayouts: ChildLayout[]; childrenRenderedInstances: RenderedInstance[]; + childrenRenderedInstanceByNames: Map; constructor( project: gdProject, @@ -175,29 +58,61 @@ export default class RenderedCustomObjectInstance extends RenderedInstance { : null; this.childrenInstances = []; - this.childrenRenderedInstances = eventBasedObject - ? mapReverseFor(0, eventBasedObject.getObjectsCount(), i => { - const childObject = eventBasedObject.getObjectAt(i); - const childObjectConfiguration = customObjectConfiguration.getChildObjectConfiguration( - childObject.getName() - ); - const childInstance = new ChildInstance(); - this.childrenInstances.push(childInstance); - const renderer = ObjectsRenderingService.createNewInstanceRenderer( - project, - layout, - // $FlowFixMe Use real object instances. - childInstance, - childObjectConfiguration, - this._pixiObject - ); - if (renderer instanceof RenderedTextInstance) { - // TODO EBO Remove this line when an alignment property is added to the text object. - renderer._pixiObject.style.align = 'center'; - } - return renderer; - }) - : []; + this.childrenLayouts = []; + this.childrenRenderedInstances = []; + this.childrenRenderedInstanceByNames = new Map(); + + if (!eventBasedObject) { + return; + } + + const childLayouts = getLayouts( + eventBasedObject, + customObjectConfiguration + ); + + mapReverseFor(0, eventBasedObject.getObjectsCount(), i => { + const childObject = eventBasedObject.getObjectAt(i); + + const childLayout = childLayouts.get(childObject.getName()) || { + isShown: true, + horizontalLayout: {}, + verticalLayout: {}, + }; + + const childObjectConfiguration = customObjectConfiguration.getChildObjectConfiguration( + childObject.getName() + ); + const childInstance = new ChildInstance(); + const renderer = ObjectsRenderingService.createNewInstanceRenderer( + project, + layout, + // $FlowFixMe Use real object instances. + childInstance, + childObjectConfiguration, + this._pixiObject + ); + if (!childLayout.isShown) { + this._pixiObject.removeChild(renderer._pixiObject); + } + + if (renderer instanceof RenderedTextInstance) { + // TODO EBO Remove this line when an alignment property is added to the text object. + renderer._pixiObject.style.align = 'center'; + } + this.childrenInstances.push(childInstance); + this.childrenLayouts.push(childLayout); + this.childrenRenderedInstances.push(renderer); + this.childrenRenderedInstanceByNames.set(childObject.getName(), renderer); + }); + + if (this.childrenRenderedInstances.length === 0) { + // Show a placeholder. + this._pixiObject = new PIXI.Sprite( + PixiResourcesLoader.getInvalidPIXITexture() + ); + this._pixiContainer.addChild(this._pixiObject); + } } /** @@ -242,6 +157,8 @@ export default class RenderedCustomObjectInstance extends RenderedInstance { } update() { + applyChildLayouts(this); + const defaultWidth = this.getDefaultWidth(); const defaultHeight = this.getDefaultHeight(); const originX = 0; @@ -249,39 +166,6 @@ export default class RenderedCustomObjectInstance extends RenderedInstance { const centerX = defaultWidth / 2; const centerY = defaultHeight / 2; - const width = this._instance.hasCustomSize() - ? this._instance.getCustomWidth() - : this.getDefaultWidth(); - const height = this._instance.hasCustomSize() - ? this._instance.getCustomHeight() - : this.getDefaultHeight(); - - for ( - let index = 0; - index < this.childrenRenderedInstances.length; - index++ - ) { - const renderedInstance = this.childrenRenderedInstances[index]; - const childInstance = this.childrenInstances[index]; - - childInstance.x = 0; - childInstance.y = 0; - childInstance.setCustomWidth(width); - childInstance.setCustomHeight(height); - renderedInstance.update(); - - if (renderedInstance instanceof RenderedTextInstance) { - // TODO EBO Remove this line when an alignment property is added to the text object. - renderedInstance._pixiObject.style.align = 'center'; - } - // This ensure objects are centered if their dimensions changed from the - // custom ones (preferred ones). - // For instance, text object dimensions change according to how the text is wrapped. - childInstance.x = (width - renderedInstance._pixiObject.width) / 2; - childInstance.y = (height - renderedInstance._pixiObject.height) / 2; - renderedInstance.update(); - } - this._pixiObject.pivot.x = centerX; this._pixiObject.pivot.y = centerY; this._pixiObject.rotation = RenderedInstance.toRad( @@ -297,19 +181,27 @@ export default class RenderedCustomObjectInstance extends RenderedInstance { (centerY - originY) * Math.abs(this._pixiObject.scale.y); } + getWidth() { + return this._instance.hasCustomSize() + ? this._instance.getCustomWidth() + : this.getDefaultWidth(); + } + + getHeight() { + return this._instance.hasCustomSize() + ? this._instance.getCustomHeight() + : this.getDefaultHeight(); + } + getDefaultWidth() { - let widthMax = 0; - for (const instance of this.childrenRenderedInstances) { - widthMax = Math.max(widthMax, instance.getDefaultWidth()); - } - return widthMax; + return this.childrenRenderedInstances.length > 0 + ? this.childrenRenderedInstances[0].getDefaultWidth() + : 48; } getDefaultHeight() { - let heightMax = 0; - for (const instance of this.childrenRenderedInstances) { - heightMax = Math.max(heightMax, instance.getDefaultHeight()); - } - return heightMax; + return this.childrenRenderedInstances.length > 0 + ? this.childrenRenderedInstances[0].getDefaultHeight() + : 48; } } diff --git a/newIDE/app/src/ObjectsRendering/Renderers/RenderedSpriteInstance.js b/newIDE/app/src/ObjectsRendering/Renderers/RenderedSpriteInstance.js index 05af451540fc..1635b5b43503 100644 --- a/newIDE/app/src/ObjectsRendering/Renderers/RenderedSpriteInstance.js +++ b/newIDE/app/src/ObjectsRendering/Renderers/RenderedSpriteInstance.js @@ -196,10 +196,10 @@ export default class RenderedSpriteInstance extends RenderedInstance { } getDefaultWidth(): number { - return Math.abs(this._pixiObject.width); + return Math.abs(this._pixiObject.texture.frame.width); } getDefaultHeight(): number { - return Math.abs(this._pixiObject.height); + return Math.abs(this._pixiObject.texture.frame.height); } }