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 @@ + + + + + + + + + + + + + + +