gluonhq / scenebuilder

Scene Builder is a visual, drag 'n' drop, layout tool for designing JavaFX application user interfaces.
https://gluonhq.com/products/scene-builder/
Other
748 stars 220 forks source link

Custom control extending `Control` base class is treated differently than build in controls like `ScrollPane` or `TitledPane` #360

Open marcin-chwedczuk opened 3 years ago

marcin-chwedczuk commented 3 years ago

PROBLEM: SceneBuilder has hardcoded behaviour for certain types of build-in custom controls. This makes it unusable when you want to use a custom control or use controls from project like controlsfx.

I actually managed to make it work, adding a few changes to the code (jfx-13 branch) - see the attached patch. The problem is that content property is not properly detected by MetadataIntrospector.

So far my PoC solution just hardcodes contents string as property name. I think it would be the best if SceneBuilder publised a small maven package with few annotations like @ContentProperty or @ChildrenProperty so that I can stick them on my custom controls properties and SceneBuilder can then figure out that the control is a kind of container.

My custom control code:

package pl.marcinchwedczuk.javafx.validation.extra;

import javafx.beans.DefaultProperty;
import javafx.beans.InvalidationListener;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.AccessibleAttribute;
import javafx.scene.AccessibleRole;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Skin;
import pl.marcinchwedczuk.javafx.validation.lib.Input;
import pl.marcinchwedczuk.javafx.validation.lib.Objection;

@DefaultProperty("content")
public class ValidationDecorator2 extends Control {

    private final SimpleListProperty<Objection> objectionsProperty =
            new SimpleListProperty<>(this, "objections", FXCollections.observableArrayList());

    private ObjectProperty<Node> contentProperty =
            new SimpleObjectProperty<Node>(this, "content", null);

    public ValidationDecorator2() {
        this.setPrefHeight(USE_COMPUTED_SIZE);
        this.setPrefWidth(USE_COMPUTED_SIZE);
    }

    @Override
    protected Skin<?> createDefaultSkin() {
        return new ValidationDecoratorSkin(this);
    }

    public final ObjectProperty<Node> contentProperty() {
        return contentProperty;
    }
    public final void setContent(Node value) {
        contentProperty().set(value);
    }
    public final Node getContent() {
        return contentProperty().get();
    }

    public SimpleListProperty<Objection> objectionsProperty() {
        return objectionsProperty;
    }
    public ObservableList<Objection> getObjections() {
        return objectionsProperty.get();
    }
    public void setObjections(ObservableList<Objection> objections) {
        objectionsProperty.set(objections);
    }

    public <UIV,MV> void displayErrorsFor(Input<UIV, MV> input) {
        this.objectionsProperty().bind(input.objectionsProperty());
    }
}

And Skin for the above:

package pl.marcinchwedczuk.javafx.validation.extra;

import javafx.beans.InvalidationListener;
import javafx.geometry.HPos;
import javafx.geometry.Pos;
import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import javafx.scene.text.Text;
import pl.marcinchwedczuk.javafx.validation.lib.Objection;
import pl.marcinchwedczuk.javafx.validation.lib.ObjectionSeverity;

import java.util.List;

class ValidationDecoratorSkin extends SkinBase<ValidationDecorator2> {
    private final VBox root;
    private final VBox decoratedComponentsContainer;
    private final VBox validationMessagesContainer;

    protected ValidationDecoratorSkin(ValidationDecorator2 control) {
        super(control);

        root = new VBox();
        root.setPrefHeight(VBox.USE_COMPUTED_SIZE);
        root.setPrefWidth(VBox.USE_COMPUTED_SIZE);

        decoratedComponentsContainer = new VBox();
        decoratedComponentsContainer.setPrefHeight(VBox.USE_COMPUTED_SIZE);
        decoratedComponentsContainer.setPrefWidth(VBox.USE_COMPUTED_SIZE);
        decoratedComponentsContainer.getStyleClass().setAll("decorated");

        validationMessagesContainer = new VBox();
        validationMessagesContainer.setPrefHeight(VBox.USE_COMPUTED_SIZE);
        validationMessagesContainer.setPrefWidth(VBox.USE_COMPUTED_SIZE);
        validationMessagesContainer.getStyleClass().setAll("validationMessages");
        validationMessagesContainer.setSpacing(2);

        root.getChildren().setAll(decoratedComponentsContainer, validationMessagesContainer);
        this.getChildren().add(root);

        getSkinnable().objectionsProperty().addListener((InvalidationListener) observable -> {
            List<Objection> objections = getSkinnable().objectionsProperty().getValue();

            var messagesFx = validationMessagesContainer.getChildren();

            if (messagesFx.size() > objections.size()) {
                messagesFx.remove(objections.size(), messagesFx.size());
            }
            while (messagesFx.size() < objections.size()) {
                messagesFx.add(new ValidationMessageFx());
            }

            for (int i = 0; i < objections.size(); i++) {
                ((ValidationMessageFx) messagesFx.get(i)).setObjection(objections.get(i));
            }

        });

        getSkinnable().contentProperty().addListener((observable, oldValue, newValue) -> {
            if (newValue == null) {
                decoratedComponentsContainer.getChildren().clear();
            } else {
                decoratedComponentsContainer.getChildren().setAll(newValue);
            }
        });

        Node content = getSkinnable().getContent();
        if (content != null) {
            decoratedComponentsContainer.getChildren().setAll(content);
        }
    }

    @Override
    protected void layoutChildren(final double x, double y,
                                  final double w, final double h) {
        double ww = snapSizeX(w);
        double hh = snapSizeY(h);
        root.resize(ww, hh);
        positionInArea(root, x, y, ww, hh, /*baseline ignored*/0, HPos.CENTER, VPos.CENTER);
    }

    @Override
    protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
        double contentWidth = snapSizeX(root.minWidth(height));
        return contentWidth + leftInset + rightInset;
    }

    @Override
    protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
        double contentHeight = root.minHeight(width);
        return snapSizeY(contentHeight) + topInset + bottomInset;
    }

    @Override
    protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
        double contentWidth = snapSizeX(root.prefWidth(height));
        return contentWidth + leftInset + rightInset;
    }

    @Override
    protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) {
        double contentHeight = root.prefHeight(width);
        return snapSizeY(contentHeight) + topInset + bottomInset;
    }

    @Override
    protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) {
        return Double.MAX_VALUE;
    }

    public static class ValidationMessageFx extends HBox {
        private final Circle circle;
        private final Text text;

        public ValidationMessageFx() {
            super.setSpacing(5);
            super.setAlignment(Pos.BASELINE_LEFT);

            this.circle = new Circle(5.0, Color.RED);
            this.text = new Text();

            this.getChildren().addAll(circle, text);
        }

        public void setObjection(Objection e) {
            this.text.setText("!!!" + e.message);
            // TODO: To styles
            this.circle.setFill(e.severity == ObjectionSeverity.ERROR ? Color.RED : Color.ORANGE);
        }
    }
}

Git Patch of PoC:

From 7994b6008b14bc881e13667c779717c1973ed116 Mon Sep 17 00:00:00 2001
From: 0xmarcin <0xmarcin+dev@gmail.com>
Date: Sat, 1 May 2021 17:13:05 +0200
Subject: [PATCH] Fix for custom controls

---
 .../kit/metadata/MetadataIntrospector.java    | 22 ++++++++++++++-----
 .../klass/CustomComponentClassMetadata.java   | 12 +++++-----
 pom.xml                                       |  1 +
 3 files changed, 24 insertions(+), 11 deletions(-)

diff --git a/kit/src/main/java/com/oracle/javafx/scenebuilder/kit/metadata/MetadataIntrospector.java b/kit/src/main/java/com/oracle/javafx/scenebuilder/kit/metadata/MetadataIntrospector.java
index 158fe94..88b9de7 100644
--- a/kit/src/main/java/com/oracle/javafx/scenebuilder/kit/metadata/MetadataIntrospector.java
+++ b/kit/src/main/java/com/oracle/javafx/scenebuilder/kit/metadata/MetadataIntrospector.java
@@ -35,6 +35,7 @@ package com.oracle.javafx.scenebuilder.kit.metadata;
 import com.oracle.javafx.scenebuilder.kit.editor.panel.inspector.editors.util.SBDuration;
 import com.oracle.javafx.scenebuilder.kit.metadata.klass.ComponentClassMetadata;
 import com.oracle.javafx.scenebuilder.kit.metadata.klass.CustomComponentClassMetadata;
+import com.oracle.javafx.scenebuilder.kit.metadata.property.ComponentPropertyMetadata;
 import com.oracle.javafx.scenebuilder.kit.metadata.property.PropertyMetadata;
 import com.oracle.javafx.scenebuilder.kit.metadata.property.value.BooleanPropertyMetadata;
 import com.oracle.javafx.scenebuilder.kit.metadata.property.value.DurationPropertyMetadata;
@@ -95,7 +96,9 @@ class MetadataIntrospector {
         final Set<PropertyMetadata> properties = new HashSet<>();
         final Set<PropertyName> hiddenProperties = Metadata.getMetadata().getHiddenProperties();
         Exception exception;
-        
+
+        CustomComponentClassMetadata result
+                = new CustomComponentClassMetadata(componentClass, ancestorMetadata);

         try {
             final Object sample = instantiate();
@@ -106,7 +109,7 @@ class MetadataIntrospector {
                         = lookupPropertyMetadata(ancestorMetadata, name);
                 if ((propertyMetadata == null) 
                         && (hiddenProperties.contains(name) == false)) {
-                    propertyMetadata = makePropertyMetadata(name, d, sample);
+                    propertyMetadata = makePropertyMetadata(name, d, sample, result);
                     if (propertyMetadata != null) {
                         properties.add(propertyMetadata);
                     }
@@ -117,9 +120,7 @@ class MetadataIntrospector {
             exception = x;
         }

-        final CustomComponentClassMetadata result 
-                = new CustomComponentClassMetadata(componentClass,  
-                ancestorMetadata, exception);
+        result.setIntrospectionException(exception);
         result.getProperties().addAll(properties);

         return result;
@@ -179,7 +180,7 @@ class MetadataIntrospector {
     }

     private PropertyMetadata makePropertyMetadata(PropertyName name, 
-            PropertyDescriptor propertyDescriptor, Object sample) {
+            PropertyDescriptor propertyDescriptor, Object sample, CustomComponentClassMetadata componentMetadata) {
         PropertyMetadata result;

         if (propertyDescriptor.getPropertyType() == null) {
@@ -298,6 +299,15 @@ class MetadataIntrospector {
                 } catch (NoSuchMethodException e) {
                     e.printStackTrace();
                 }
+            } else if (propertyType == javafx.scene.Node.class) {
+                if ("content".equals(name.getName())) {
+                    result = new ComponentPropertyMetadata(
+                            name,
+                            componentMetadata,
+                            false);
+                } else {
+                    result = null;
+                }
             } else {
                 result = null;
             }
diff --git a/kit/src/main/java/com/oracle/javafx/scenebuilder/kit/metadata/klass/CustomComponentClassMetadata.java b/kit/src/main/java/com/oracle/javafx/scenebuilder/kit/metadata/klass/CustomComponentClassMetadata.java
index f00c9ed..4118abd 100644
--- a/kit/src/main/java/com/oracle/javafx/scenebuilder/kit/metadata/klass/CustomComponentClassMetadata.java
+++ b/kit/src/main/java/com/oracle/javafx/scenebuilder/kit/metadata/klass/CustomComponentClassMetadata.java
@@ -37,18 +37,20 @@ package com.oracle.javafx.scenebuilder.kit.metadata.klass;
  */
 public class CustomComponentClassMetadata extends ComponentClassMetadata {

-    private final Exception introspectionException;
+    private Exception introspectionException;

     public CustomComponentClassMetadata(Class<?> klass, 
-            ComponentClassMetadata parentMetadata, Exception introspectionException) {
+            ComponentClassMetadata parentMetadata) {
         super(klass, parentMetadata);
-        this.introspectionException = introspectionException;
     }
-    
+
     public Exception getIntrospectionException() {
         return introspectionException;
     }
-    
+
+    public void setIntrospectionException(Exception introspectionException) {
+        this.introspectionException = introspectionException;
+    }

     /*
      * Object
diff --git a/pom.xml b/pom.xml
index 454669e..ac79428 100644
--- a/pom.xml
+++ b/pom.xml
@@ -45,6 +45,7 @@
                         <mainClass>${main.class.name}</mainClass>
                         <options>
                             <option>--add-opens=javafx.fxml/javafx.fxml=ALL-UNNAMED</option>
+                            <option>-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=1044</option>
                         </options>
                     </configuration>
                 </plugin>
-- 
2.31.1
mkpaz commented 1 year ago

Bump. It would be a really nice addition. I think simply supporting comma-separated values list in the @DefaultProperty would be much easier than publishing another Maven artifact.