Merge a chosen ViewModel class with one Component child per Address.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-30 18:28:48 +01:00
co-authored by Cursor
31 changed files with 1063 additions and 57 deletions
@@ -42,12 +42,13 @@ public class BindingList {
* Applies every binding element in this list.
*
* @param namespace objects addressed by {@code fx:id}
* @param viewModel the ViewModel for this View
* @param viewModel the ViewModel for this View, or a replacement that exposes JavaFX properties
* by name
* @param controller the Controller for this View, or {@code null} when the document names none
* @param root the scene-graph root of this View
* @param context the container that creates a named Converter
*/
void apply(Map<String, Object> namespace, ViewModel viewModel, Object controller, Parent root,
void apply(Map<String, Object> namespace, Object viewModel, Object controller, Parent root,
ApplicationContext context) {
for (Object element : elements) {
if (element instanceof DirectBinding directBinding) {
@@ -18,7 +18,8 @@ import java.util.Map;
* <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
* property holds a non-null Model. {@link ViewModelClass} on the Component names the ViewModel
* class constructed for that Model. A Component that implements {@link HasViewModel} is handed
* that ViewModel after the property is set and before those bindings run.
*/
final class Components {
@@ -31,9 +32,11 @@ final class Components {
* <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. The first Component
* of a class in one load is the cached singleton. A second Component of that class is a new
* injected instance, so the document can show two of them.
* type itself. A Component that extends a JavaFX class is also left to FXML when the document
* names that JavaFX class: the container would otherwise hand back the Component. A failure
* while creating a known Component is not caught. The first Component of a class in one load
* is the cached singleton. A second Component of that class is a new injected instance, so the
* document can show two of them.
*
* @param type the type FXML is about to construct
* @param context the container that creates Components
@@ -42,12 +45,15 @@ final class Components {
static Builder<?> builder(Class<?> type, ApplicationContext context) {
Object component;
try {
component = context.getComponent(type);
component = context.getComponent(type.asSubclass(Object.class));
} catch (IllegalArgumentException notAComponent) {
return null;
}
if (component.getClass() != type) {
return null;
}
if (!LoadSession.claimFirst(type)) {
component = context.createComponent(type);
component = context.createComponent(type.asSubclass(Object.class));
}
Object created = component;
return () -> load(created, context);
@@ -112,9 +118,9 @@ final class Components {
return;
}
applied[0] = true;
ViewModel viewModel = ViewModels.of(newValue);
if (component instanceof HasViewModel hasViewModel) {
hasViewModel.setViewModel(viewModel);
Object viewModel = ViewModels.of(newValue, ViewModels.namedOn(component.getClass()));
if (component instanceof HasViewModel hasViewModel && viewModel instanceof ViewModel bound) {
hasViewModel.setViewModel(bound);
}
Parent root = (Parent) component;
for (BindingList list : lists) {
@@ -35,7 +35,7 @@ final class ComputedBindingApplier {
*/
static void apply(
Map<String, Object> namespace,
ViewModel viewModel,
Object viewModel,
Object controller,
Parent root,
ComputedBinding binding) {
@@ -47,7 +47,9 @@ final class ComputedBindingApplier {
Runnable refresh = refresh(controller, binding.getMethod(), target);
refresh.run();
watchControls(root, target, refresh);
viewModel.addAttributeListener((observable, oldValue, newValue) -> refresh.run());
if (viewModel instanceof ViewModel model) {
model.addAttributeListener((observable, oldValue, newValue) -> refresh.run());
}
}
private static Runnable refresh(Object controller, String methodValue, Property<Object> target) {
@@ -6,6 +6,7 @@ import javafx.collections.ObservableList;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
/**
@@ -24,12 +25,13 @@ final class DirectBindingApplier {
* @param binding the declaration
* @param context the container that creates a named Converter
*/
static void apply(Map<String, Object> namespace, ViewModel viewModel, DirectBinding binding,
static void apply(Map<String, Object> namespace, Object viewModel, DirectBinding binding,
ApplicationContext context) {
Object control = namespace.get(binding.getControl());
if (control == null) {
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
}
selectComponentViewModel(control, viewModel, binding);
Resolved resolved = resolve(viewModel, binding.getPath());
if (resolved == null) {
return;
@@ -107,13 +109,16 @@ final class DirectBindingApplier {
* @return the Attribute for the last name, or {@code null} when a Complex attribute
* along the Path is null
*/
private static Resolved resolve(ViewModel viewModel, String path) {
private static Resolved resolve(Object viewModel, String path) {
String[] names = path.split("\\.", -1);
ViewModel current = viewModel;
Object current = viewModel;
for (int index = 0; index < names.length - 1; index++) {
if (!(current instanceof ViewModel stepModel)) {
throw missing(path);
}
String name = names[index];
Property<?> step = current.property(name);
if (step == null || !current.isComplex(name)) {
Property<?> step = stepModel.property(name);
if (step == null || !stepModel.isComplex(name)) {
throw missing(path);
}
Object nested = step.getValue();
@@ -126,11 +131,74 @@ final class DirectBindingApplier {
current = child;
}
String last = names[names.length - 1];
Property<?> attribute = current.property(last);
Property<?> attribute = propertyOf(current, last);
if (attribute == null) {
throw missing(path);
}
return new Resolved(attribute, current.isComplex(last), current, last);
boolean complex = current instanceof ViewModel stepped && stepped.isComplex(last);
ViewModel owner = current instanceof ViewModel model ? model : null;
return new Resolved(attribute, complex, owner, last);
}
/**
* Builds the ViewModel named on a Component before the Path is read.
* <p>
* The binding sets the Component's model property. Reading the Path would otherwise build a
* generic ViewModel first and fix that class.
*
* @param control the control named by the binding
* @param viewModel the ViewModel for this View
* @param binding the declaration
*/
private static void selectComponentViewModel(Object control, Object viewModel, DirectBinding binding) {
if (!"model".equals(binding.getProperty())) {
return;
}
Class<?> requested = ViewModels.namedOn(control.getClass());
if (requested == null) {
return;
}
Object instance = instanceAt(viewModel, binding.getPath());
if (instance == null) {
return;
}
ViewModels.of(instance, requested);
}
private static Object instanceAt(Object viewModel, String path) {
if (!(viewModel instanceof ViewModel root)) {
return null;
}
Object current = root.model();
for (String name : path.split("\\.", -1)) {
if (current == null) {
return null;
}
current = readAttribute(current, name);
}
return current;
}
private static Object readAttribute(Object bean, String name) {
String suffix = Character.toUpperCase(name.charAt(0)) + name.substring(1);
for (String prefix : List.of("get", "is")) {
try {
Method method = bean.getClass().getMethod(prefix + suffix);
return method.invoke(bean);
} catch (NoSuchMethodException notThisPrefix) {
continue;
} catch (IllegalAccessException | InvocationTargetException failure) {
throw new IllegalStateException("Could not read attribute " + name, failure);
}
}
throw new IllegalArgumentException("No read-write attribute for path \"" + name + "\"");
}
private static Property<?> propertyOf(Object target, String name) {
if (target instanceof ViewModel viewModel) {
return viewModel.property(name);
}
return NamedProperties.get(target, name);
}
private static IllegalArgumentException missing(String path) {
@@ -0,0 +1,69 @@
package de.neitzel.fx.view;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.xml.sax.SAXException;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
/**
* Reads the Controller class named by an FXML document.
*/
final class FxmlControllers {
private static final String FXML_NAMESPACE = "http://javafx.com/fxml";
private FxmlControllers() {
}
/**
* Returns the Controller class named on the document root, or {@code null} when the document
* names none.
*
* @param fxml the FXML document
* @return the Controller class, or {@code null}
*/
static Class<?> controllerClass(URL fxml) {
String name = controllerName(fxml);
if (name == null || name.isEmpty()) {
return null;
}
try {
return Class.forName(name);
} catch (ClassNotFoundException failure) {
throw new IllegalArgumentException("No controller " + name, failure);
}
}
private static String controllerName(URL fxml) {
try (InputStream input = fxml.openStream()) {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
Element root = factory.newDocumentBuilder().parse(input).getDocumentElement();
String named = root.getAttributeNS(FXML_NAMESPACE, "controller");
if (named != null && !named.isEmpty()) {
return named;
}
NamedNodeMap attributes = root.getAttributes();
for (int index = 0; index < attributes.getLength(); index++) {
Node attribute = attributes.item(index);
if ("controller".equals(attribute.getLocalName())
|| "fx:controller".equals(attribute.getNodeName())) {
return attribute.getNodeValue();
}
}
return null;
} catch (IOException | ParserConfigurationException | SAXException failure) {
throw new IllegalStateException("Could not read controller from " + fxml, failure);
}
}
}
@@ -9,15 +9,16 @@ import javafx.scene.Parent;
public final class LoadedView {
private final Parent root;
private final ViewModel viewModel;
private final Object viewModel;
/**
* Creates the result of one load.
*
* @param root the scene-graph root
* @param viewModel the ViewModel built for the Model instance
* @param viewModel the ViewModel built for the Model instance, or a replacement that exposes
* JavaFX properties by name
*/
LoadedView(Parent root, ViewModel viewModel) {
LoadedView(Parent root, Object viewModel) {
this.root = root;
this.viewModel = viewModel;
}
@@ -33,11 +34,18 @@ public final class LoadedView {
/**
* Returns the ViewModel the View is bound to.
* <p>
* A generic ViewModel, and a subclass of it, are returned as that ViewModel. A replacement
* that does not extend {@link ViewModel} is addressed through {@link #property(String)}.
*
* @return the ViewModel
* @throws IllegalStateException when the View is bound to a replacement
*/
public ViewModel getViewModel() {
return viewModel;
if (viewModel instanceof ViewModel bound) {
return bound;
}
throw new IllegalStateException("View is bound to " + viewModel.getClass().getName());
}
/**
@@ -46,11 +54,15 @@ public final class LoadedView {
* The property's class follows the Attribute's declared type. The name {@code latestMessage}
* is a string property holding the text of the latest Message.
*
* @param name the Attribute name, or {@code latestMessage}
* @return the property, or {@code null} when that name is not a read-write Attribute
* and is not {@code latestMessage}
* @param name the Attribute name, {@code latestMessage}, or a JavaFX property declared on
* the ViewModel
* @return the property, or {@code null} when that name is not a read-write Attribute,
* not {@code latestMessage}, and not a JavaFX property declared on the ViewModel
*/
public Property<?> property(String name) {
return viewModel.property(name);
if (viewModel instanceof ViewModel bound) {
return bound.property(name);
}
return NamedProperties.get(viewModel, name);
}
}
@@ -0,0 +1,43 @@
package de.neitzel.fx.view;
import javafx.beans.property.Property;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
/**
* Reads a JavaFX property declared on an object by name.
* <p>
* A name {@code badge} is the no-argument method {@code badgeProperty}.
*/
final class NamedProperties {
private NamedProperties() {
}
/**
* Returns the JavaFX property named on {@code bean}.
*
* @param bean the object that declares the property
* @param name the property name
* @return the property, or {@code null} when {@code bean} declares none of that name
*/
static Property<?> get(Object bean, String name) {
String methodName = name + "Property";
try {
Method method = bean.getClass().getMethod(methodName);
if (!Property.class.isAssignableFrom(method.getReturnType())) {
return null;
}
Object value = method.invoke(bean);
if (value instanceof Property<?> property) {
return property;
}
return null;
} catch (NoSuchMethodException notDeclared) {
return null;
} catch (IllegalAccessException | InvocationTargetException failure) {
throw new IllegalStateException("Could not read property " + name, failure);
}
}
}
@@ -26,17 +26,17 @@ final class RepeatApplier {
* on either side is visible in the pane.
*
* @param namespace objects addressed by {@code fx:id}
* @param viewModel the ViewModel for this View
* @param viewModel the ViewModel for this View, or a replacement that exposes the collection by name
* @param repeat the declaration
* @param context the container that creates each Component
*/
static void apply(Map<String, Object> namespace, ViewModel viewModel, Repeat repeat,
static void apply(Map<String, Object> namespace, Object viewModel, Repeat repeat,
ApplicationContext context) {
Object named = namespace.get(repeat.getPane());
if (!(named instanceof Pane pane)) {
throw new IllegalArgumentException("No pane \"" + repeat.getPane() + "\"");
}
Property<?> property = viewModel.property(repeat.getCollection());
Property<?> property = collection(viewModel, repeat.getCollection());
if (property == null || !(property.getValue() instanceof ObservableList<?> values)) {
throw new IllegalArgumentException("No collection \"" + repeat.getCollection() + "\"");
}
@@ -47,6 +47,13 @@ final class RepeatApplier {
listen(pane, type, context, values);
}
private static Property<?> collection(Object viewModel, String name) {
if (viewModel instanceof ViewModel model) {
return model.property(name);
}
return NamedProperties.get(viewModel, name);
}
@SuppressWarnings("unchecked")
private static void listen(Pane pane, Class<?> type, ApplicationContext context, ObservableList<?> values) {
ObservableList<Object> elements = (ObservableList<Object>) values;
@@ -0,0 +1,31 @@
package de.neitzel.fx.view;
/**
* Controller used when a document names none.
* <p>
* A View with only Direct bindings still loads. The library hands this controller the ViewModel
* after construction and before bindings, the same way it hands a Controller the developer wrote.
*/
final class SuppliedController implements HasViewModel {
private ViewModel viewModel;
/**
* Receives the ViewModel for this View.
*
* @param viewModel the ViewModel built for the Model instance
*/
@Override
public void setViewModel(ViewModel viewModel) {
this.viewModel = viewModel;
}
/**
* Returns the ViewModel handed to this controller.
*
* @return the ViewModel, or {@code null} when none has been handed over
*/
ViewModel viewModel() {
return viewModel;
}
}
@@ -31,8 +31,16 @@ public final class ViewLoader {
* control and the Model keep their current values. A collection of simple values is one shared
* list: the Direct binding sets the control property to that
* {@link javafx.collections.ObservableList}, so an add or remove is visible on both sides.
* 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
* A path that is not a read-write Attribute fails the load. The context creates a Controller
* the document names, and injects that Controller's services. When the document names no
* Controller, the library supplies one. A Controller that implements {@link HasViewModel}
* receives the ViewModel after construction and before bindings. {@link ViewModelClass} on that
* Controller names the ViewModel class. The library constructs it with the Model instance
* before load. With no annotation, the library builds the generic ViewModel. The first View of a
* Model instance fixes that class. A later View that names a different class fails the load,
* and the earlier View stays as it was. A later View that names none reuses the ViewModel.
* The context creates a
* Converter when a Direct binding names one. A converter that cannot produce a value
* keeps the previous value and records one error Message on the ViewModel that owns the last
* Attribute, with that Attribute's name as the source. A parent carries a copy with the source
* extended. It does not fail the load.
@@ -41,8 +49,9 @@ public final class ViewLoader {
* and the cached singleton stays the first one. 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.
* model property becomes a non-null Model. {@link ViewModelClass} on the Component names the
* ViewModel class for that Model. A Component that implements {@link HasViewModel} receives
* that ViewModel after the property is set and before those bindings run.
* A {@link Repeat} in the same list names a pane, a Component class, and a collection
* Attribute. One new Component child is placed in that pane for each Model instance, in list
* order. Adding or removing an element updates that pane. Those instances are created with
@@ -63,7 +72,8 @@ public final class ViewLoader {
* @throws IllegalArgumentException when a Direct binding names a path that is not a read-write Attribute
*/
public static LoadedView load(URL fxml, Object model, ApplicationContext context) {
ViewModel viewModel = ViewModels.of(model);
Class<?> viewModelClass = ViewModels.namedOn(FxmlControllers.controllerClass(fxml));
Object viewModel = ViewModels.of(model, viewModelClass);
LoadSession session = LoadSession.open();
try {
FXMLLoader loader = new FXMLLoader(fxml);
@@ -73,8 +83,15 @@ public final class ViewLoader {
if (!(loaded instanceof Parent root)) {
throw new IllegalArgumentException("View root must be a parent");
}
Object controller = loader.getController();
if (controller == null) {
controller = new SuppliedController();
}
if (controller instanceof HasViewModel hasViewModel && viewModel instanceof ViewModel bound) {
hasViewModel.setViewModel(bound);
}
for (BindingList bindingList : session.bindingLists()) {
bindingList.apply(loader.getNamespace(), viewModel, loader.getController(), root, context);
bindingList.apply(loader.getNamespace(), viewModel, controller, root, context);
}
return new LoadedView(root, viewModel);
} catch (IOException failure) {
@@ -92,10 +92,13 @@ public class ViewModel {
/**
* Builds a ViewModel for one Model instance.
* <p>
* A subclass in another package calls this constructor and may add JavaFX properties under
* new names. A Binding element addresses those names the same way it addresses an Attribute.
*
* @param model the Model instance
*/
ViewModel(Object model) {
protected ViewModel(Object model) {
this.model = model;
ViewModels.register(model, this);
for (PropertyDescriptor descriptor : descriptors(model.getClass())) {
@@ -108,9 +111,9 @@ public class ViewModel {
Class<?> type = descriptor.getPropertyType();
Object current = read(getter, name);
if (isComplex(type)) {
Property<?> property = new SimpleObjectProperty<>(
current == null ? null : ViewModels.of(current));
LazyComplex property = new LazyComplex(current);
watchComplex(property, setter, name);
property.attached();
properties.put(name, property);
complexAttributes.add(name);
} else {
@@ -131,15 +134,20 @@ public class ViewModel {
* Model attributes. It is a string property. A Direct binding can read it. An in-place
* update of that Message refreshes the text and does not replace the Message.
*
* @param name the Attribute name, or {@code latestMessage}
* @return the property, or {@code null} when that name is not a read-write Attribute
* and is not {@code latestMessage}
* @param name the Attribute name, {@code latestMessage}, or a JavaFX property declared on
* this ViewModel
* @return the property, or {@code null} when that name is not a read-write Attribute,
* not {@code latestMessage}, and not a JavaFX property declared on this ViewModel
*/
public Property<?> property(String name) {
if ("latestMessage".equals(name)) {
return latestMessageText;
}
return properties.get(name);
Property<?> stored = properties.get(name);
if (stored != null) {
return stored;
}
return declaredProperty(name);
}
/**
@@ -173,6 +181,16 @@ public class ViewModel {
}
}
/**
* Returns a JavaFX property a subclass declares under {@code name}, such as {@code titleProperty}.
*
* @param name the property name
* @return the property, or {@code null} when this class declares none
*/
private Property<?> declaredProperty(String name) {
return NamedProperties.get(this, name);
}
private static List<PropertyDescriptor> descriptors(Class<?> type) {
try {
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
@@ -288,10 +306,41 @@ public class ViewModel {
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
}
private void watchComplex(Property<?> property, Method setter, String name) {
if (property.getValue() instanceof ViewModel child) {
attachChild(name, child);
/**
* Holds a Complex attribute and builds its ViewModel the first time the value is read.
* <p>
* Attaching the change listener reads the value. That read is ignored until
* {@link #attached()} so a Component annotation can name the class first.
*/
private static final class LazyComplex extends SimpleObjectProperty<Object> {
private final Object current;
private boolean realized;
private boolean attaching = true;
private LazyComplex(Object current) {
this.current = current;
}
@Override
public Object get() {
if (!attaching && !realized) {
realized = true;
super.set(current == null ? null : ViewModels.of(current));
}
return super.get();
}
private void attached() {
attaching = false;
}
private boolean pending(Object instance) {
return !realized && current == instance;
}
}
private void watchComplex(Property<?> property, Method setter, String name) {
property.addListener((observable, oldValue, newValue) -> {
if (oldValue != newValue) {
detachChild(name);
@@ -300,6 +349,7 @@ public class ViewModel {
write(setter, name, written);
if (oldValue != newValue && newValue instanceof ViewModel child) {
attachChild(name, child);
realizeShared(child.model());
}
});
}
@@ -310,6 +360,25 @@ public class ViewModel {
* @param name the Attribute that leads to the child
* @param child the child ViewModel
*/
/**
* Realizes every other Complex attribute that holds the same Model instance.
* <p>
* Home and work can name one address. Reading one still carries a notice under both
* attribute names.
*
* @param instance the Model instance just selected
*/
private void realizeShared(Object instance) {
if (instance == null) {
return;
}
for (Property<?> property : properties.values()) {
if (property instanceof LazyComplex lazy && lazy.pending(instance)) {
lazy.get();
}
}
}
private void attachChild(String name, ViewModel child) {
childMirrors.put(name, ChildMessageMirror.attach(this, name, child));
}
@@ -0,0 +1,28 @@
package de.neitzel.fx.view;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Names the ViewModel class for a Controller or a Component.
* <p>
* The library constructs that class with the Model instance for the first View of the instance.
* A later View that names a different class fails to load. A later View that names none reuses
* the ViewModel already built. The container does not create the ViewModel.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ViewModelClass {
/**
* Returns the ViewModel class.
* <p>
* The class has a constructor that takes the Model instance. It may extend {@link ViewModel}
* or be another object that exposes JavaFX properties by name.
*
* @return the ViewModel class
*/
Class<?> value();
}
@@ -1,5 +1,7 @@
package de.neitzel.fx.view;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.IdentityHashMap;
import java.util.Map;
@@ -10,23 +12,107 @@ import java.util.Map;
*/
final class ViewModels {
private static final Map<Object, ViewModel> BY_INSTANCE = new IdentityHashMap<>();
private static final Map<Object, Object> BY_INSTANCE = new IdentityHashMap<>();
private ViewModels() {
}
/**
* Returns the ViewModel for a Model instance, building it when none exists yet.
* Returns the ViewModel for a Model instance, building the generic one when none exists yet.
*
* @param model the Model instance
* @return the ViewModel for that instance
* @return the ViewModel for that instance, or a replacement that exposes JavaFX properties by name
*/
static ViewModel of(Object model) {
ViewModel existing = BY_INSTANCE.get(model);
static Object of(Object model) {
return of(model, null);
}
/**
* Returns the ViewModel for a Model instance.
* <p>
* Builds {@code viewModelClass} with the Model when none exists yet. A null class, or
* {@link ViewModel} itself, builds the generic ViewModel. The class may extend {@link ViewModel}
* or be a replacement that exposes JavaFX properties by name. The container does not create it.
* The first View fixes the class. A later View that names a different class fails. A later
* View that names none reuses the ViewModel.
*
* @param model the Model instance
* @param viewModelClass the class named by {@link ViewModelClass}, or {@code null}
* @return the ViewModel for that instance, or the replacement
*/
static Object of(Object model, Class<?> viewModelClass) {
Object existing = BY_INSTANCE.get(model);
if (existing != null) {
if (viewModelClass != null && viewModelClass != ViewModel.class
&& existing.getClass() != viewModelClass) {
throw new IllegalArgumentException(
"ViewModel is " + existing.getClass().getName()
+ " but the View names " + viewModelClass.getName());
}
return existing;
}
return new ViewModel(model);
if (viewModelClass == null || viewModelClass == ViewModel.class) {
return new ViewModel(model);
}
return construct(model, viewModelClass);
}
/**
* Returns the ViewModel class named on a Controller or Component, or {@code null} when the
* type names none.
*
* @param type the Controller or Component class
* @return the named class, or {@code null}
*/
static Class<?> namedOn(Class<?> type) {
if (type == null) {
return null;
}
ViewModelClass annotation = type.getAnnotation(ViewModelClass.class);
if (annotation == null) {
return null;
}
return annotation.value();
}
private static Object construct(Object model, Class<?> viewModelClass) {
Constructor<?> constructor = constructor(model, viewModelClass);
constructor.setAccessible(true);
try {
Object created = constructor.newInstance(model);
if (!(created instanceof ViewModel)) {
register(model, created);
}
return created;
} catch (InvocationTargetException failure) {
if (failure.getCause() instanceof RuntimeException runtime) {
throw runtime;
}
throw new IllegalStateException("Could not construct " + viewModelClass.getName(), failure);
} catch (IllegalAccessException | InstantiationException failure) {
throw new IllegalStateException("Could not construct " + viewModelClass.getName(), failure);
}
}
private static Constructor<?> constructor(Object model, Class<?> viewModelClass) {
Constructor<?> chosen = null;
for (Constructor<?> candidate : viewModelClass.getDeclaredConstructors()) {
if (candidate.getParameterCount() != 1) {
continue;
}
Class<?> parameter = candidate.getParameterTypes()[0];
if (!parameter.isAssignableFrom(model.getClass())) {
continue;
}
if (chosen == null || chosen.getParameterTypes()[0].isAssignableFrom(parameter)) {
chosen = candidate;
}
}
if (chosen == null) {
throw new IllegalArgumentException(
"No constructor taking the Model on " + viewModelClass.getName());
}
return chosen;
}
/**
@@ -35,7 +121,7 @@ final class ViewModels {
* @param model the Model instance
* @param viewModel the ViewModel under construction
*/
static void register(Object model, ViewModel viewModel) {
static void register(Object model, Object viewModel) {
BY_INSTANCE.put(model, viewModel);
}
}
@@ -0,0 +1,193 @@
package de.neitzel.fx.view;
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.beans.property.Property;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
class ChooseControllerAndViewModelTest {
@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 labelShowsTheNameRecordedWhenTheViewModelWasHandedOver() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ChooseControllerAndViewModelTest.class.getResource("handed-name.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label handedLabel = (Label) loaded.getRoot().lookup("#handedLabel");
assertEquals("handed-Ada", handedLabel.getText());
});
}
@Test
void labelShowsTheGreetingInjectedIntoTheController() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ChooseControllerAndViewModelTest.class.getResource("desk-greeting.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label deskLabel = (Label) loaded.getRoot().lookup("#deskLabel");
assertEquals("from-the-desk", deskLabel.getText());
});
}
@Test
void labelShowsTheTitleFromTheNamedViewModel() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ChooseControllerAndViewModelTest.class.getResource("titled-name.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label titleLabel = (Label) loaded.getRoot().lookup("#titleLabel");
assertEquals("titled-Ada", titleLabel.getText());
@SuppressWarnings("unchecked")
Property<String> title = (Property<String>) loaded.property("title");
assertNotNull(title);
assertEquals("titled-Ada", title.getValue());
title.setValue("titled-Grace");
assertEquals("titled-Grace", titleLabel.getText());
});
}
@Test
void labelShowsTheBadgeFromAReplacementViewModel() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ChooseControllerAndViewModelTest.class.getResource("badge-name.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label badgeLabel = (Label) loaded.getRoot().lookup("#badgeLabel");
assertEquals("badge-Ada", badgeLabel.getText());
@SuppressWarnings("unchecked")
Property<String> badge = (Property<String>) loaded.property("badge");
assertNotNull(badge);
assertEquals("badge-Ada", badge.getValue());
badge.setValue("badge-Grace");
assertEquals("badge-Grace", badgeLabel.getText());
});
}
@Test
void componentLabelShowsTheCaptionFromTheNamedViewModel() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAddress(new Address("Main"));
URL fxml = ChooseControllerAndViewModelTest.class.getResource("caption-address.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label captionLabel = (Label) loaded.getRoot().lookup("#captionLabel");
assertEquals("caption-Main", captionLabel.getText());
});
}
@Test
void aLaterViewThatNamesADifferentClassFailsAndLeavesTheFirstView() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL titled = ChooseControllerAndViewModelTest.class.getResource("titled-name.fxml");
URL badge = ChooseControllerAndViewModelTest.class.getResource("badge-name.fxml");
assertNotNull(titled);
assertNotNull(badge);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
LoadedView first = ViewLoader.load(titled, person, context);
Label titleLabel = (Label) first.getRoot().lookup("#titleLabel");
assertEquals("titled-Ada", titleLabel.getText());
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> ViewLoader.load(badge, person, context));
assertTrue(failure.getMessage().contains("TitledViewModel"), failure.getMessage());
assertTrue(failure.getMessage().contains("BadgeViewModel"), failure.getMessage());
assertEquals("titled-Ada", titleLabel.getText());
});
}
@Test
void aLaterViewWithNoAnnotationReusesTheViewModel() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL named = ChooseControllerAndViewModelTest.class.getResource("named-person.fxml");
URL plain = ChooseControllerAndViewModelTest.class.getResource("name-both-ways.fxml");
assertNotNull(named);
assertNotNull(plain);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
LoadedView first = ViewLoader.load(named, person, context);
LoadedView second = ViewLoader.load(plain, person, context);
TextField firstName = (TextField) first.getRoot().lookup("#nameField");
TextField secondName = (TextField) second.getRoot().lookup("#nameField");
assertEquals("Ada", firstName.getText());
assertEquals("Ada", secondName.getText());
firstName.setText("Grace");
assertEquals("Grace", secondName.getText());
assertEquals("Grace", person.getName());
});
}
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,12 @@
package de.neitzel.fx.view.chosen;
import de.neitzel.fx.view.ViewModelClass;
import jakarta.inject.Singleton;
/**
* Controller that names {@link BadgeViewModel} as a replacement ViewModel.
*/
@Singleton
@ViewModelClass(BadgeViewModel.class)
public class BadgeController {
}
@@ -0,0 +1,33 @@
package de.neitzel.fx.view.chosen;
import de.neitzel.fx.view.fixture.Person;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
/**
* Replacement for the library ViewModel. It exposes a badge property by name and does not
* extend {@link de.neitzel.fx.view.ViewModel}.
*/
public class BadgeViewModel {
private final StringProperty badge = new SimpleStringProperty();
/**
* Builds the replacement and sets the badge from the person's name.
*
* @param model the Model instance
*/
public BadgeViewModel(Object model) {
Person person = (Person) model;
badge.set("badge-" + person.getName());
}
/**
* Returns the badge property a Binding element addresses by name.
*
* @return the badge property
*/
public StringProperty badgeProperty() {
return badge;
}
}
@@ -0,0 +1,44 @@
package de.neitzel.fx.view.chosen;
import de.neitzel.fx.view.ViewModelClass;
import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.layout.VBox;
/**
* Component that names {@link StreetTitleViewModel} for the address it shows.
*/
@Singleton
@ViewModelClass(StreetTitleViewModel.class)
public class CaptionAddressForm 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 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);
}
}
@@ -0,0 +1,32 @@
package de.neitzel.fx.view.chosen;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
/**
* Controller that shows a greeting supplied by an injected service.
*/
@Singleton
public class DeskController {
private final DeskGreeting greeting;
/**
* Creates a controller that reads its label from the injected greeting.
*
* @param greeting the service that supplies the greeting
*/
@Inject
public DeskController(DeskGreeting greeting) {
this.greeting = greeting;
}
/**
* Returns the greeting from the injected service.
*
* @return the label text
*/
public String deskGreeting() {
return greeting.text();
}
}
@@ -0,0 +1,19 @@
package de.neitzel.fx.view.chosen;
import jakarta.inject.Singleton;
/**
* Service whose text a Controller must show, so a test can see that injection ran.
*/
@Singleton
public class DeskGreeting {
/**
* Returns the greeting written on the label.
*
* @return the greeting
*/
public String text() {
return "from-the-desk";
}
}
@@ -0,0 +1,33 @@
package de.neitzel.fx.view.chosen;
import de.neitzel.fx.view.HasViewModel;
import de.neitzel.fx.view.ViewModel;
import jakarta.inject.Singleton;
/**
* Controller whose computed value is taken from the ViewModel handed over before bindings.
*/
@Singleton
public class HandedNameController implements HasViewModel {
private String handed;
/**
* Records a label value that is available only from the handed ViewModel.
*
* @param viewModel the ViewModel for this View
*/
@Override
public void setViewModel(ViewModel viewModel) {
handed = "handed-" + viewModel.property("name").getValue();
}
/**
* Returns the value recorded when the ViewModel was handed over.
*
* @return the label text
*/
public String handedName() {
return handed;
}
}
@@ -0,0 +1,34 @@
package de.neitzel.fx.view.chosen;
import de.neitzel.fx.view.ViewModel;
import de.neitzel.fx.view.fixture.Address;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
/**
* ViewModel for an address that adds a caption taken from the street.
*/
public class StreetTitleViewModel extends ViewModel {
private final StringProperty caption = new SimpleStringProperty();
/**
* Builds a ViewModel and sets the caption from the address street.
*
* @param model the address
*/
public StreetTitleViewModel(Object model) {
super(model);
Address address = (Address) model;
caption.set("caption-" + address.getStreet());
}
/**
* Returns the caption property a Binding element addresses by name.
*
* @return the caption property
*/
public StringProperty captionProperty() {
return caption;
}
}
@@ -0,0 +1,12 @@
package de.neitzel.fx.view.chosen;
import de.neitzel.fx.view.ViewModelClass;
import jakarta.inject.Singleton;
/**
* Controller that names {@link TitledViewModel} for its View.
*/
@Singleton
@ViewModelClass(TitledViewModel.class)
public class TitledController {
}
@@ -0,0 +1,34 @@
package de.neitzel.fx.view.chosen;
import de.neitzel.fx.view.ViewModel;
import de.neitzel.fx.view.fixture.Person;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
/**
* ViewModel subclass that adds a title property taken from the Model's name.
*/
public class TitledViewModel extends ViewModel {
private final StringProperty title = new SimpleStringProperty();
/**
* Builds a ViewModel and sets the title from the person's name.
*
* @param model the Model instance
*/
public TitledViewModel(Object model) {
super(model);
Person person = (Person) model;
title.set("titled-" + person.getName());
}
/**
* Returns the title property a Binding element addresses by name.
*
* @return the title property
*/
public StringProperty titleProperty() {
return title;
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
fx:controller="de.neitzel.fx.view.chosen.BadgeController">
<fx:define>
<BindingList>
<DirectBinding control="badgeLabel" property="text" path="badge" direction="view"/>
</BindingList>
</fx:define>
<Label fx:id="badgeLabel" id="badgeLabel"/>
</VBox>
@@ -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.chosen.CaptionAddressForm?>
<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>
<CaptionAddressForm fx:id="addressForm"/>
</StackPane>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?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="captionLabel" property="text" path="caption" direction="view"/>
</BindingList>
</fx:define>
<Label fx:id="captionLabel" id="captionLabel"/>
</fx:root>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.ComputedBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
fx:controller="de.neitzel.fx.view.chosen.DeskController">
<fx:define>
<BindingList>
<ComputedBinding control="deskLabel" property="text" method="#deskGreeting"/>
</BindingList>
</fx:define>
<Label fx:id="deskLabel" id="deskLabel"/>
</VBox>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.ComputedBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
fx:controller="de.neitzel.fx.view.chosen.HandedNameController">
<fx:define>
<BindingList>
<ComputedBinding control="handedLabel" property="text" method="#handedName"/>
</BindingList>
</fx:define>
<Label fx:id="handedLabel" id="handedLabel"/>
</VBox>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
fx:controller="de.neitzel.fx.view.chosen.TitledController">
<fx:define>
<BindingList>
<DirectBinding control="nameField" property="text" path="name" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="nameField" id="nameField"/>
</VBox>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
fx:controller="de.neitzel.fx.view.chosen.TitledController">
<fx:define>
<BindingList>
<DirectBinding control="titleLabel" property="text" path="title" direction="view"/>
</BindingList>
</fx:define>
<Label fx:id="titleLabel" id="titleLabel"/>
</VBox>
@@ -82,6 +82,16 @@ public class ApplicationContext {
return (T) instance;
}
/**
* Loads a dependency whose type was read by reflection.
*
* @param type the dependency type
* @return the component for that type
*/
private Object component(Class<?> type) {
return getComponent(type.asSubclass(Object.class));
}
/**
* Creates a new injected instance without replacing a cached singleton.
* <p>
@@ -134,20 +144,20 @@ public class ApplicationContext {
Class<?>[] paramTypes = injectionConstructor.getParameterTypes();
Object[] parameters = new Object[paramTypes.length];
for (int i = 0; i < paramTypes.length; i++) {
parameters[i] = getComponent(paramTypes[i]);
parameters[i] = component(paramTypes[i]);
}
Object instance = injectionConstructor.newInstance(parameters);
for (Field field : data.getInjectableFields()) {
field.setAccessible(true);
field.set(instance, getComponent(field.getType()));
field.set(instance, component(field.getType()));
}
for (Method setter : data.getInjectableSetters()) {
setter.setAccessible(true);
Class<?> dependencyType = setter.getParameterTypes()[0];
setter.invoke(instance, getComponent(dependencyType));
setter.invoke(instance, component(dependencyType));
}
return instance;