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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user