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");