From a092654ded4696806a0133756e45898a22b1f443 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Thu, 12 Nov 2026 07:27:31 +0100 Subject: [PATCH] Apply Component FXML attributes after injected initialization --- fx/README.md | 8 ++ .../java/de/neitzel/fx/view/Components.java | 85 ++++++++++++++++++- .../fx/view/EditAddressInAComponentTest.java | 43 ++++++++++ .../fx/view/component/AddressForm.java | 16 ++++ .../fx/view/fixture/SampleAddress.java | 9 ++ .../fx/view/address-component-attributes.fxml | 8 ++ .../address-component-invalid-number.fxml | 3 + .../address-component-invalid-unknown.fxml | 3 + 8 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 fx/src/test/java/de/neitzel/fx/view/fixture/SampleAddress.java create mode 100644 fx/src/test/resources/de/neitzel/fx/view/address-component-attributes.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/address-component-invalid-number.fxml create mode 100644 fx/src/test/resources/de/neitzel/fx/view/address-component-invalid-unknown.fxml diff --git a/fx/README.md b/fx/README.md index 44e8ab2..16c1c9b 100644 --- a/fx/README.md +++ b/fx/README.md @@ -247,6 +247,14 @@ A component's `@ViewModelClass` applies to that component's model, not to the pa ## Component +Component elements accept writable FXML properties, including custom attributes and inherited +layout properties such as `prefWidth`. The container constructs the Component first; its own +FXML and `initialize()` run before the containing document's property values are applied. +JavaFX performs the normal property conversion and rejects unknown properties or invalid values. +Object references such as `model="$address"` retain their identity and are assigned after the +Component's model listener is installed. Static layout constraints such as `GridPane.rowIndex` +remain handled by the containing FXMLLoader. + A component is a class the container can create. It extends a pane, loads `ClassName.fxml` from its own package through `fx:root`, and is the controller of that file. It has no `fx:controller`. It exposes one JavaFX property named `model`. `AddressForm` edits the home address. `VisitForm` edits one visit in the repeated list. `NoteForm` edits a note. `VisitRow` edits one table row. Each is `@Singleton`, extends `VBox` or `HBox`, and has `modelProperty()`. diff --git a/fx/src/main/java/de/neitzel/fx/view/Components.java b/fx/src/main/java/de/neitzel/fx/view/Components.java index 2938917..21fbc4f 100644 --- a/fx/src/main/java/de/neitzel/fx/view/Components.java +++ b/fx/src/main/java/de/neitzel/fx/view/Components.java @@ -6,7 +6,12 @@ import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.util.Builder; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.AbstractMap; +import java.util.LinkedHashMap; +import java.util.Set; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.URL; @@ -58,8 +63,84 @@ final class Components { if (!LoadSession.claimFirst(type)) { component = context.createComponent(type.asSubclass(Object.class)); } - Object created = component; - return () -> load(created, context); + return new ComponentBuilder(component, context); + } + + /** + * Collects instance properties while FXML constructs an injected Component. + * Properties are applied after its own document and model listener are ready. + */ + private static final class ComponentBuilder extends AbstractMap implements Builder { + /** The injected instance. */ + private final Object component; + /** The context used to load its document. */ + private final ApplicationContext context; + /** Values already resolved by the containing FXMLLoader. */ + private final Map values = new LinkedHashMap<>(); + + /** + * Creates a builder for an injected instance. + * @param component the instance + * @param context its dependency context + */ + private ComponentBuilder(Object component, ApplicationContext context) { + this.component = component; + this.context = context; + } + + /** + * Records a property for application after Component initialization. + * @param key the property name + * @param value the resolved value + * @return the previous value + */ + @Override + public Object put(String key, Object value) { + return values.put(key, value); + } + + /** + * Returns the recorded properties. + * @return the property entries + */ + @Override + public Set> entrySet() { + return values.entrySet(); + } + + /** + * Loads the Component and applies properties with standard FXML coercion. + * @return the initialized Component + */ + @Override + public Object build() { + Object result = load(component, context); + if (!values.isEmpty()) { + // A second fx:root load uses the public FXMLLoader API for bean setters, + // enum/numeric coercion and diagnostics, without depending on BeanAdapter + // internals or serializing values (which may be Model object references). + FXMLLoader properties = new FXMLLoader(); + properties.setRoot(result); + properties.setClassLoader(result.getClass().getClassLoader()); + StringBuilder document = new StringBuilder(" entry : values.entrySet()) { + String variable = "componentValue" + index++; + properties.getNamespace().put(variable, entry.getValue()); + document.append(' ').append(entry.getKey()).append("=\"$") + .append(variable).append('"'); + } + document.append("/>"); + try { + properties.load(new ByteArrayInputStream(document.toString().getBytes(StandardCharsets.UTF_8))); + } catch (IOException failure) { + throw new IllegalStateException("Could not apply Component properties to " + + result.getClass().getName(), failure); + } + } + return result; + } } /** diff --git a/fx/src/test/java/de/neitzel/fx/view/EditAddressInAComponentTest.java b/fx/src/test/java/de/neitzel/fx/view/EditAddressInAComponentTest.java index a8971cc..41e8d86 100644 --- a/fx/src/test/java/de/neitzel/fx/view/EditAddressInAComponentTest.java +++ b/fx/src/test/java/de/neitzel/fx/view/EditAddressInAComponentTest.java @@ -145,6 +145,49 @@ class EditAddressInAComponentTest { }); } + /** + * Attributes retain FXML conversion and identity while injection precedes setters. + */ + @Test + void componentAttributesApplyAfterInitialization() throws Exception { + onFx(() -> { + var context = new ApplicationContext("de.neitzel.fx.view.component"); + var loaded = ViewLoader.load(getClass().getResource("address-component-attributes.fxml"), + new Person("Ada"), context); + var first = (AddressForm) loaded.getRoot().lookup("#first"); + var second = (AddressForm) loaded.getRoot().lookup("#second"); + assertEquals("Home & work", first.getCaption()); + assertEquals(190, first.getPrefWidth()); + assertEquals(360, first.getMaxWidth()); + assertEquals(false, first.isVisible()); + assertEquals(javafx.geometry.Pos.CENTER_RIGHT, first.getAlignment()); + assertEquals(1, javafx.scene.layout.GridPane.getColumnIndex(first)); + assertSame(first.getModel(), second.getModel()); + assertEquals("Second", second.getCaption()); + assertEquals(280, second.getPrefWidth()); + assertSame(first, context.getComponent(AddressForm.class)); + TextField field = (TextField) first.lookup("#streetField"); + assertEquals("Main", field.getText()); + field.setText("Oak"); + assertEquals("Oak", ((Address) first.getModel()).getStreet()); + }); + } + + /** + * Invalid names and malformed values fail rather than disappearing in the builder. + */ + @Test + void invalidComponentAttributesFailTheLoad() throws Exception { + onFx(() -> { + for (String kind : java.util.List.of("unknown", "number")) { + var context = new ApplicationContext("de.neitzel.fx.view.component"); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> ViewLoader.load(getClass().getResource("address-component-invalid-" + kind + ".fxml"), + new Person("Ada"), context)); + } + }); + } + /** * Runs one check on the JavaFX application thread. * diff --git a/fx/src/test/java/de/neitzel/fx/view/component/AddressForm.java b/fx/src/test/java/de/neitzel/fx/view/component/AddressForm.java index ce1389b..c72716d 100644 --- a/fx/src/test/java/de/neitzel/fx/view/component/AddressForm.java +++ b/fx/src/test/java/de/neitzel/fx/view/component/AddressForm.java @@ -67,6 +67,22 @@ public class AddressForm extends VBox implements HasViewModel { streetLabel.setText(captions.prefix()); } + /** + * Returns the custom caption after initialization. + * @return the caption + */ + public String getCaption() { + return streetLabel.getText(); + } + + /** + * Sets a custom FXML attribute; the label must already be injected. + * @param caption the caption + */ + public void setCaption(String caption) { + streetLabel.setText(caption); + } + /** * Returns the model property a parent view sets with a direct binding. * diff --git a/fx/src/test/java/de/neitzel/fx/view/fixture/SampleAddress.java b/fx/src/test/java/de/neitzel/fx/view/fixture/SampleAddress.java new file mode 100644 index 0000000..4e44167 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/fixture/SampleAddress.java @@ -0,0 +1,9 @@ +package de.neitzel.fx.view.fixture; + +/** Address that FXML can declare to test object-valued Component attributes. */ +public class SampleAddress extends Address { + /** Creates an initially empty address. */ + public SampleAddress() { + super(""); + } +} diff --git a/fx/src/test/resources/de/neitzel/fx/view/address-component-attributes.fxml b/fx/src/test/resources/de/neitzel/fx/view/address-component-attributes.fxml new file mode 100644 index 0000000..fa21094 --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/address-component-attributes.fxml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/address-component-invalid-number.fxml b/fx/src/test/resources/de/neitzel/fx/view/address-component-invalid-number.fxml new file mode 100644 index 0000000..2339ab6 --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/address-component-invalid-number.fxml @@ -0,0 +1,3 @@ + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/address-component-invalid-unknown.fxml b/fx/src/test/resources/de/neitzel/fx/view/address-component-invalid-unknown.fxml new file mode 100644 index 0000000..7a9d56c --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/address-component-invalid-unknown.fxml @@ -0,0 +1,3 @@ + + +