diff --git a/fx/src/main/java/de/neitzel/fx/view/Components.java b/fx/src/main/java/de/neitzel/fx/view/Components.java
new file mode 100644
index 0000000..780ecc0
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/Components.java
@@ -0,0 +1,108 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.injection.ApplicationContext;
+import javafx.beans.property.Property;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Parent;
+import javafx.util.Builder;
+
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Loads a Component from {@code ClassName.fxml} in the Component's package.
+ *
+ * The container creates the instance. The document uses {@code fx:root} with that instance as
+ * the root and as the Controller. Bindings declared in the document wait until the model
+ * property holds a non-null Model. A Component that implements {@link HasViewModel} is handed
+ * that ViewModel after the property is set and before those bindings run.
+ */
+final class Components {
+
+ private Components() {
+ }
+
+ /**
+ * Returns a builder for a type the container knows.
+ *
+ * An unknown type, such as a JavaFX control, makes {@link ApplicationContext#getComponent}
+ * throw {@link IllegalArgumentException}. This method returns null so FXML constructs that
+ * type itself. A failure while creating a known Component is not caught.
+ *
+ * @param type the type FXML is about to construct
+ * @param context the container that creates Components
+ * @return a builder for a Component, or {@code null} when the type is not a Component
+ */
+ static Builder> builder(Class> type, ApplicationContext context) {
+ Object component;
+ try {
+ component = context.getComponent(type);
+ } catch (IllegalArgumentException notAComponent) {
+ return null;
+ }
+ return () -> load(component, context);
+ }
+
+ private static Object load(Object component, ApplicationContext context) {
+ Class> type = component.getClass();
+ String document = type.getSimpleName() + ".fxml";
+ URL fxml = type.getResource(document);
+ if (fxml == null) {
+ throw new IllegalArgumentException("No component document " + document + " for " + type.getName());
+ }
+ LoadSession nested = LoadSession.nest();
+ try {
+ FXMLLoader loader = new FXMLLoader(fxml);
+ loader.setRoot(component);
+ loader.setController(component);
+ loader.setBuilderFactory(nestedType -> builder(nestedType, context));
+ loader.load();
+ watchModel(component, List.copyOf(nested.bindingLists()), loader.getNamespace(), context);
+ return component;
+ } catch (IOException failure) {
+ throw new IllegalStateException("Could not load component " + fxml, failure);
+ } finally {
+ LoadSession.unnest();
+ }
+ }
+
+ private static void watchModel(Object component, List lists, Map namespace,
+ ApplicationContext context) {
+ Property model = modelProperty(component);
+ boolean[] applied = {false};
+ model.addListener((observable, oldValue, newValue) -> {
+ if (newValue == null || applied[0]) {
+ return;
+ }
+ applied[0] = true;
+ ViewModel viewModel = ViewModels.of(newValue);
+ if (component instanceof HasViewModel hasViewModel) {
+ hasViewModel.setViewModel(viewModel);
+ }
+ Parent root = (Parent) component;
+ for (BindingList list : lists) {
+ list.apply(namespace, viewModel, component, root, context);
+ }
+ });
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Property modelProperty(Object component) {
+ try {
+ Method method = component.getClass().getMethod("modelProperty");
+ Object value = method.invoke(component);
+ if (!(value instanceof Property> property)) {
+ throw new IllegalArgumentException("modelProperty did not return a property");
+ }
+ return (Property) property;
+ } catch (NoSuchMethodException failure) {
+ throw new IllegalArgumentException("No model property on " + component.getClass().getName(), failure);
+ } catch (IllegalAccessException | InvocationTargetException failure) {
+ throw new IllegalStateException("Could not read model property", failure);
+ }
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/HasViewModel.java b/fx/src/main/java/de/neitzel/fx/view/HasViewModel.java
new file mode 100644
index 0000000..6b9bcf3
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/HasViewModel.java
@@ -0,0 +1,17 @@
+package de.neitzel.fx.view;
+
+/**
+ * Receives the ViewModel selected for a Controller or a Component.
+ *
+ * A Component is called after its model property is set to a non-null Model and before that
+ * Component's bindings run.
+ */
+public interface HasViewModel {
+
+ /**
+ * Receives the ViewModel for the Model this object is bound to.
+ *
+ * @param viewModel the selected ViewModel
+ */
+ void setViewModel(ViewModel viewModel);
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/LoadSession.java b/fx/src/main/java/de/neitzel/fx/view/LoadSession.java
index 33ea106..3e14e81 100644
--- a/fx/src/main/java/de/neitzel/fx/view/LoadSession.java
+++ b/fx/src/main/java/de/neitzel/fx/view/LoadSession.java
@@ -1,6 +1,8 @@
package de.neitzel.fx.view;
+import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.Deque;
import java.util.List;
/**
@@ -8,11 +10,12 @@ import java.util.List;
*
* FXML objects declared in {@code fx:define} are not scene-graph children and have no
* {@code fx:id} in the accepted document shape, so each {@link BindingList} registers
- * itself here from its constructor.
+ * itself here from its constructor. A Component document opens a nested session, so its
+ * lists stay off the parent session.
*/
final class LoadSession {
- private static final ThreadLocal CURRENT = new ThreadLocal<>();
+ private static final ThreadLocal> STACK = new ThreadLocal<>();
private final List bindingLists = new ArrayList<>();
@@ -25,16 +28,38 @@ final class LoadSession {
* @return the open session
*/
static LoadSession open() {
+ Deque stack = new ArrayDeque<>();
LoadSession session = new LoadSession();
- CURRENT.set(session);
+ stack.push(session);
+ STACK.set(stack);
return session;
}
+ /**
+ * Opens a nested session for a Component document.
+ *
+ * Binding lists created until {@link #unnest()} register here, not on the parent session.
+ *
+ * @return the nested session
+ */
+ static LoadSession nest() {
+ LoadSession session = new LoadSession();
+ STACK.get().push(session);
+ return session;
+ }
+
+ /**
+ * Restores the session that was current before {@link #nest()}.
+ */
+ static void unnest() {
+ STACK.get().pop();
+ }
+
/**
* Drops the session for this thread.
*/
static void close() {
- CURRENT.remove();
+ STACK.remove();
}
/**
@@ -43,10 +68,11 @@ final class LoadSession {
* @param bindingList the list FXML just constructed
*/
static void register(BindingList bindingList) {
- LoadSession session = CURRENT.get();
- if (session != null) {
- session.bindingLists.add(bindingList);
+ Deque stack = STACK.get();
+ if (stack == null || stack.isEmpty()) {
+ return;
}
+ stack.peek().bindingLists.add(bindingList);
}
/**
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
index 7a1d168..247496d 100644
--- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
@@ -34,6 +34,12 @@ public final class ViewLoader {
* A path that is not a read-write Attribute fails the load. The context creates a Controller,
* and a Converter when a Direct binding names one. A converter that cannot produce a value
* keeps the previous value and records one error Message. It does not fail the load.
+ * A parent document names a Component class as an element. The context creates that Component
+ * and injects its services. The Component document {@code ClassName.fxml}, in the same package,
+ * is loaded with the Component as {@code fx:root} and as the Controller. That document's
+ * bindings are not applied during this load. They run when the Component's model property
+ * becomes a non-null Model. A Component that implements {@link HasViewModel} receives that
+ * ViewModel after the property is set and before those bindings run.
*
* @param fxml the FXML document
* @param model the Model instance
@@ -47,6 +53,7 @@ public final class ViewLoader {
try {
FXMLLoader loader = new FXMLLoader(fxml);
loader.setControllerFactory(type -> controller(context, type));
+ loader.setBuilderFactory(type -> Components.builder(type, context));
Object loaded = loader.load();
if (!(loaded instanceof Parent root)) {
throw new IllegalArgumentException("View root must be a parent");
diff --git a/fx/src/test/java/de/neitzel/fx/view/EditAddressInAComponentTest.java b/fx/src/test/java/de/neitzel/fx/view/EditAddressInAComponentTest.java
new file mode 100644
index 0000000..4216b76
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/EditAddressInAComponentTest.java
@@ -0,0 +1,162 @@
+package de.neitzel.fx.view;
+
+import de.neitzel.fx.view.component.AddressForm;
+import de.neitzel.fx.view.component.Envelope;
+import de.neitzel.fx.view.component.Letter;
+import de.neitzel.fx.view.fixture.Address;
+import de.neitzel.fx.view.fixture.Person;
+import de.neitzel.injection.ApplicationContext;
+import javafx.application.Platform;
+import javafx.scene.control.Label;
+import javafx.scene.control.TextField;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.net.URL;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.fail;
+
+class EditAddressInAComponentTest {
+
+ @BeforeAll
+ static void startJavaFx() throws InterruptedException {
+ CountDownLatch started = new CountDownLatch(1);
+ try {
+ Platform.startup(started::countDown);
+ } catch (IllegalStateException alreadyStarted) {
+ started.countDown();
+ }
+ if (!started.await(10, TimeUnit.SECONDS)) {
+ fail("JavaFX toolkit did not start");
+ }
+ }
+
+ @Test
+ void editingTheStreetUpdatesThatAddress() throws Exception {
+ onFx(() -> {
+ Address address = new Address("Main");
+ Person person = new Person("Ada");
+ person.setAddress(address);
+ URL fxml = EditAddressInAComponentTest.class.getResource("address-component.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ AddressForm form = (AddressForm) loaded.getRoot().lookup("#addressForm");
+ assertNotNull(form);
+ assertSame(address, form.getModel());
+ TextField streetField = (TextField) form.lookup("#streetField");
+ assertEquals("Main", streetField.getText());
+ streetField.setText("Oak");
+ assertEquals("Oak", address.getStreet());
+ });
+ }
+
+ @Test
+ void nullAddressLeavesTheModelUnsetAndKeepsTheStreetText() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ URL fxml = EditAddressInAComponentTest.class.getResource("address-component.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ AddressForm form = (AddressForm) loaded.getRoot().lookup("#addressForm");
+ assertNotNull(form);
+ assertNull(form.getModel());
+ TextField streetField = (TextField) form.lookup("#streetField");
+ assertEquals("kept", streetField.getText());
+ assertNull(person.getAddress());
+ });
+ }
+
+ @Test
+ void streetLabelShowsTheInjectedPrefix() throws Exception {
+ onFx(() -> {
+ Person person = new Person("Ada");
+ person.setAddress(new Address("Main"));
+ URL fxml = EditAddressInAComponentTest.class.getResource("address-component.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ AddressForm form = (AddressForm) loaded.getRoot().lookup("#addressForm");
+ Label streetLabel = (Label) form.lookup("#streetLabel");
+ assertEquals("Lane", streetLabel.getText());
+ });
+ }
+
+ @Test
+ void componentReceivesTheAddressViewModelBeforeItsBindingsRun() throws Exception {
+ onFx(() -> {
+ Address address = new Address("Main");
+ Person person = new Person("Ada");
+ person.setAddress(address);
+ URL fxml = EditAddressInAComponentTest.class.getResource("address-component.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
+ LoadedView loaded = ViewLoader.load(fxml, person, context);
+ AddressForm form = (AddressForm) loaded.getRoot().lookup("#addressForm");
+ assertSame(loaded.property("address").getValue(), form.receivedViewModel());
+ assertEquals("kept", form.streetTextAtHandOff());
+ TextField streetField = (TextField) form.lookup("#streetField");
+ assertEquals("Main", streetField.getText());
+ });
+ }
+
+ @Test
+ void nestedComponentShowsTheInnerStreet() throws Exception {
+ onFx(() -> {
+ Address address = new Address("Main");
+ Letter letter = new Letter(new Envelope(address));
+ URL fxml = EditAddressInAComponentTest.class.getResource("letter-envelope.fxml");
+ assertNotNull(fxml);
+ ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
+ LoadedView loaded = ViewLoader.load(fxml, letter, context);
+ TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
+ assertEquals("Main", streetField.getText());
+ streetField.setText("Oak");
+ assertEquals("Oak", address.getStreet());
+ });
+ }
+
+ private static void onFx(FxAction action) throws Exception {
+ if (Platform.isFxApplicationThread()) {
+ action.run();
+ return;
+ }
+ AtomicReference thrown = new AtomicReference<>();
+ CountDownLatch done = new CountDownLatch(1);
+ Platform.runLater(() -> {
+ try {
+ action.run();
+ } catch (Throwable failure) {
+ thrown.set(failure);
+ } finally {
+ done.countDown();
+ }
+ });
+ if (!done.await(10, TimeUnit.SECONDS)) {
+ fail("JavaFX thread did not finish");
+ }
+ Throwable failure = thrown.get();
+ if (failure instanceof Exception exception) {
+ throw exception;
+ }
+ if (failure instanceof Error error) {
+ throw error;
+ }
+ if (failure != null) {
+ throw new AssertionError(failure);
+ }
+ }
+
+ @FunctionalInterface
+ private interface FxAction {
+ void run() throws Exception;
+ }
+}
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
new file mode 100644
index 0000000..3dc3b56
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/component/AddressForm.java
@@ -0,0 +1,107 @@
+package de.neitzel.fx.view.component;
+
+import de.neitzel.fx.view.HasViewModel;
+import de.neitzel.fx.view.ViewModel;
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.fxml.FXML;
+import javafx.scene.control.Label;
+import javafx.scene.control.TextField;
+import javafx.scene.layout.VBox;
+
+/**
+ * Component that shows one address.
+ *
+ * The container injects the caption service. The street label shows that service's prefix.
+ */
+@Singleton
+public class AddressForm extends VBox implements HasViewModel {
+
+ private final StreetCaption captions;
+ private final ObjectProperty model = new SimpleObjectProperty<>(this, "model");
+
+ @FXML
+ private Label streetLabel;
+
+ @FXML
+ private TextField streetField;
+
+ private ViewModel receivedViewModel;
+ private String streetTextAtHandOff;
+
+ /**
+ * Creates a form that reads its street label from the injected caption.
+ *
+ * @param captions the service that supplies the street prefix
+ */
+ @Inject
+ public AddressForm(StreetCaption captions) {
+ this.captions = captions;
+ }
+
+ /**
+ * Writes the injected prefix onto the street label.
+ */
+ @FXML
+ public void initialize() {
+ streetLabel.setText(captions.prefix());
+ }
+
+ /**
+ * Returns the model property a parent View sets with a Direct binding.
+ *
+ * @return the model property
+ */
+ public ObjectProperty modelProperty() {
+ return model;
+ }
+
+ /**
+ * Returns the address this component shows.
+ *
+ * @return the address, or {@code null} when none is set
+ */
+ public Object getModel() {
+ return model.get();
+ }
+
+ /**
+ * Sets the address this component shows.
+ *
+ * @param model the address
+ */
+ public void setModel(Object model) {
+ this.model.set(model);
+ }
+
+ /**
+ * Records the ViewModel handed to this component, and the street text at that moment.
+ *
+ * @param viewModel the ViewModel selected for the address
+ */
+ @Override
+ public void setViewModel(ViewModel viewModel) {
+ this.receivedViewModel = viewModel;
+ this.streetTextAtHandOff = streetField.getText();
+ }
+
+ /**
+ * Returns the ViewModel this component was handed.
+ *
+ * @return the ViewModel, or {@code null} when none was handed over
+ */
+ public ViewModel receivedViewModel() {
+ return receivedViewModel;
+ }
+
+ /**
+ * Returns the street text observed when the ViewModel was handed over.
+ *
+ * @return the street text at hand-off
+ */
+ public String streetTextAtHandOff() {
+ return streetTextAtHandOff;
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/component/Envelope.java b/fx/src/test/java/de/neitzel/fx/view/component/Envelope.java
new file mode 100644
index 0000000..40f27be
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/component/Envelope.java
@@ -0,0 +1,38 @@
+package de.neitzel.fx.view.component;
+
+import de.neitzel.fx.view.fixture.Address;
+
+/**
+ * Ordinary model that holds one address, used to nest an address component.
+ */
+public class Envelope {
+
+ private Address address;
+
+ /**
+ * Creates an envelope for the given address.
+ *
+ * @param address the address inside the envelope
+ */
+ public Envelope(Address address) {
+ this.address = address;
+ }
+
+ /**
+ * Returns the address.
+ *
+ * @return the address
+ */
+ public Address getAddress() {
+ return address;
+ }
+
+ /**
+ * Sets the address.
+ *
+ * @param address the address
+ */
+ public void setAddress(Address address) {
+ this.address = address;
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/component/EnvelopeForm.java b/fx/src/test/java/de/neitzel/fx/view/component/EnvelopeForm.java
new file mode 100644
index 0000000..d16aefa
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/component/EnvelopeForm.java
@@ -0,0 +1,42 @@
+package de.neitzel.fx.view.component;
+
+import jakarta.inject.Singleton;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.scene.layout.VBox;
+
+/**
+ * Component that shows one envelope and the address component inside it.
+ */
+@Singleton
+public class EnvelopeForm extends VBox {
+
+ private final ObjectProperty model = new SimpleObjectProperty<>(this, "model");
+
+ /**
+ * Returns the model property a parent View sets with a Direct binding.
+ *
+ * @return the model property
+ */
+ public ObjectProperty modelProperty() {
+ return model;
+ }
+
+ /**
+ * Returns the envelope this component shows.
+ *
+ * @return the envelope, or {@code null} when none is set
+ */
+ public Object getModel() {
+ return model.get();
+ }
+
+ /**
+ * Sets the envelope this component shows.
+ *
+ * @param model the envelope
+ */
+ public void setModel(Object model) {
+ this.model.set(model);
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/component/Letter.java b/fx/src/test/java/de/neitzel/fx/view/component/Letter.java
new file mode 100644
index 0000000..474cb33
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/component/Letter.java
@@ -0,0 +1,36 @@
+package de.neitzel.fx.view.component;
+
+/**
+ * Ordinary model that holds one envelope.
+ */
+public class Letter {
+
+ private Envelope envelope;
+
+ /**
+ * Creates a letter for the given envelope.
+ *
+ * @param envelope the envelope
+ */
+ public Letter(Envelope envelope) {
+ this.envelope = envelope;
+ }
+
+ /**
+ * Returns the envelope.
+ *
+ * @return the envelope
+ */
+ public Envelope getEnvelope() {
+ return envelope;
+ }
+
+ /**
+ * Sets the envelope.
+ *
+ * @param envelope the envelope
+ */
+ public void setEnvelope(Envelope envelope) {
+ this.envelope = envelope;
+ }
+}
diff --git a/fx/src/test/java/de/neitzel/fx/view/component/StreetCaption.java b/fx/src/test/java/de/neitzel/fx/view/component/StreetCaption.java
new file mode 100644
index 0000000..e91e914
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/component/StreetCaption.java
@@ -0,0 +1,19 @@
+package de.neitzel.fx.view.component;
+
+import jakarta.inject.Singleton;
+
+/**
+ * Service whose text an address component must show, so a test can see that injection ran.
+ */
+@Singleton
+public class StreetCaption {
+
+ /**
+ * Returns the prefix written on the street label.
+ *
+ * @return the prefix
+ */
+ public String prefix() {
+ return "Lane";
+ }
+}
diff --git a/fx/src/test/resources/de/neitzel/fx/view/address-component.fxml b/fx/src/test/resources/de/neitzel/fx/view/address-component.fxml
new file mode 100644
index 0000000..e715d56
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/address-component.fxml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/component/AddressForm.fxml b/fx/src/test/resources/de/neitzel/fx/view/component/AddressForm.fxml
new file mode 100644
index 0000000..b166240
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/component/AddressForm.fxml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/component/EnvelopeForm.fxml b/fx/src/test/resources/de/neitzel/fx/view/component/EnvelopeForm.fxml
new file mode 100644
index 0000000..e7ca4e7
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/component/EnvelopeForm.fxml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fx/src/test/resources/de/neitzel/fx/view/letter-envelope.fxml b/fx/src/test/resources/de/neitzel/fx/view/letter-envelope.fxml
new file mode 100644
index 0000000..8a70af4
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/letter-envelope.fxml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+