Let a reused Component edit one nested Model after the container injects it.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-30 18:01:07 +01:00
co-authored by Cursor
parent f8c65e444e
commit b1256bf690
14 changed files with 633 additions and 7 deletions
@@ -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.
* <p>
* 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.
* <p>
* 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<BindingList> lists, Map<String, Object> namespace,
ApplicationContext context) {
Property<Object> 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<Object> 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<Object>) 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);
}
}
}
@@ -0,0 +1,17 @@
package de.neitzel.fx.view;
/**
* Receives the ViewModel selected for a Controller or a Component.
* <p>
* 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);
}
@@ -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;
* <p>
* 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<LoadSession> CURRENT = new ThreadLocal<>();
private static final ThreadLocal<Deque<LoadSession>> STACK = new ThreadLocal<>();
private final List<BindingList> bindingLists = new ArrayList<>();
@@ -25,16 +28,38 @@ final class LoadSession {
* @return the open session
*/
static LoadSession open() {
Deque<LoadSession> 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.
* <p>
* 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<LoadSession> stack = STACK.get();
if (stack == null || stack.isEmpty()) {
return;
}
stack.peek().bindingLists.add(bindingList);
}
/**
@@ -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");
@@ -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<Throwable> 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;
}
}
@@ -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.
* <p>
* 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<Object> 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<Object> 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;
}
}
@@ -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;
}
}
@@ -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<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* Returns the model property a parent View sets with a Direct binding.
*
* @return the model property
*/
public ObjectProperty<Object> 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);
}
}
@@ -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;
}
}
@@ -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";
}
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.StackPane?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<?import de.neitzel.fx.view.component.AddressForm?>
<StackPane xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="addressForm" property="model" path="address" direction="both"/>
</BindingList>
</fx:define>
<AddressForm fx:id="addressForm"/>
</StackPane>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<fx:root type="javafx.scene.layout.VBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="street" direction="both"/>
</BindingList>
</fx:define>
<Label fx:id="streetLabel" id="streetLabel"/>
<TextField fx:id="streetField" id="streetField" text="kept"/>
</fx:root>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<?import de.neitzel.fx.view.component.AddressForm?>
<fx:root type="javafx.scene.layout.VBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="addressForm" property="model" path="address" direction="both"/>
</BindingList>
</fx:define>
<AddressForm fx:id="addressForm"/>
</fx:root>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.StackPane?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<?import de.neitzel.fx.view.component.EnvelopeForm?>
<StackPane xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="envelopeForm" property="model" path="envelope" direction="both"/>
</BindingList>
</fx:define>
<EnvelopeForm fx:id="envelopeForm"/>
</StackPane>