Let a Controller name the ViewModel class and receive it before bindings.
A second View reuses that one instance, and fails the load when it names a different class. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,12 +42,13 @@ public class BindingList {
|
|||||||
* Applies every binding element in this list.
|
* Applies every binding element in this list.
|
||||||
*
|
*
|
||||||
* @param namespace objects addressed by {@code fx:id}
|
* @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 controller the Controller for this View, or {@code null} when the document names none
|
||||||
* @param root the scene-graph root of this View
|
* @param root the scene-graph root of this View
|
||||||
* @param context the container that creates a named Converter
|
* @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) {
|
ApplicationContext context) {
|
||||||
for (Object element : elements) {
|
for (Object element : elements) {
|
||||||
if (element instanceof DirectBinding directBinding) {
|
if (element instanceof DirectBinding directBinding) {
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import java.util.Map;
|
|||||||
* <p>
|
* <p>
|
||||||
* The container creates the instance. The document uses {@code fx:root} with that instance as
|
* 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
|
* 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.
|
* that ViewModel after the property is set and before those bindings run.
|
||||||
*/
|
*/
|
||||||
final class Components {
|
final class Components {
|
||||||
@@ -31,7 +32,9 @@ final class Components {
|
|||||||
* <p>
|
* <p>
|
||||||
* An unknown type, such as a JavaFX control, makes {@link ApplicationContext#getComponent}
|
* An unknown type, such as a JavaFX control, makes {@link ApplicationContext#getComponent}
|
||||||
* throw {@link IllegalArgumentException}. This method returns null so FXML constructs that
|
* throw {@link IllegalArgumentException}. This method returns null so FXML constructs that
|
||||||
* type itself. A failure while creating a known Component is not caught.
|
* 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.
|
||||||
*
|
*
|
||||||
* @param type the type FXML is about to construct
|
* @param type the type FXML is about to construct
|
||||||
* @param context the container that creates Components
|
* @param context the container that creates Components
|
||||||
@@ -44,6 +47,9 @@ final class Components {
|
|||||||
} catch (IllegalArgumentException notAComponent) {
|
} catch (IllegalArgumentException notAComponent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (component.getClass() != type) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return () -> load(component, context);
|
return () -> load(component, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,9 +85,9 @@ final class Components {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applied[0] = true;
|
applied[0] = true;
|
||||||
ViewModel viewModel = ViewModels.of(newValue);
|
Object viewModel = ViewModels.of(newValue, ViewModels.namedOn(component.getClass()));
|
||||||
if (component instanceof HasViewModel hasViewModel) {
|
if (component instanceof HasViewModel hasViewModel && viewModel instanceof ViewModel bound) {
|
||||||
hasViewModel.setViewModel(viewModel);
|
hasViewModel.setViewModel(bound);
|
||||||
}
|
}
|
||||||
Parent root = (Parent) component;
|
Parent root = (Parent) component;
|
||||||
for (BindingList list : lists) {
|
for (BindingList list : lists) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ final class ComputedBindingApplier {
|
|||||||
*/
|
*/
|
||||||
static void apply(
|
static void apply(
|
||||||
Map<String, Object> namespace,
|
Map<String, Object> namespace,
|
||||||
ViewModel viewModel,
|
Object viewModel,
|
||||||
Object controller,
|
Object controller,
|
||||||
Parent root,
|
Parent root,
|
||||||
ComputedBinding binding) {
|
ComputedBinding binding) {
|
||||||
@@ -47,7 +47,9 @@ final class ComputedBindingApplier {
|
|||||||
Runnable refresh = refresh(controller, binding.getMethod(), target);
|
Runnable refresh = refresh(controller, binding.getMethod(), target);
|
||||||
refresh.run();
|
refresh.run();
|
||||||
watchControls(root, target, refresh);
|
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) {
|
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.InvocationTargetException;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,12 +25,13 @@ final class DirectBindingApplier {
|
|||||||
* @param binding the declaration
|
* @param binding the declaration
|
||||||
* @param context the container that creates a named Converter
|
* @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) {
|
ApplicationContext context) {
|
||||||
Object control = namespace.get(binding.getControl());
|
Object control = namespace.get(binding.getControl());
|
||||||
if (control == null) {
|
if (control == null) {
|
||||||
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
|
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
|
||||||
}
|
}
|
||||||
|
selectComponentViewModel(control, viewModel, binding);
|
||||||
Resolved resolved = resolve(viewModel, binding.getPath());
|
Resolved resolved = resolve(viewModel, binding.getPath());
|
||||||
if (resolved == null) {
|
if (resolved == null) {
|
||||||
return;
|
return;
|
||||||
@@ -107,13 +109,16 @@ final class DirectBindingApplier {
|
|||||||
* @return the Attribute for the last name, or {@code null} when a Complex attribute
|
* @return the Attribute for the last name, or {@code null} when a Complex attribute
|
||||||
* along the Path is null
|
* 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);
|
String[] names = path.split("\\.", -1);
|
||||||
ViewModel current = viewModel;
|
Object current = viewModel;
|
||||||
for (int index = 0; index < names.length - 1; index++) {
|
for (int index = 0; index < names.length - 1; index++) {
|
||||||
|
if (!(current instanceof ViewModel stepModel)) {
|
||||||
|
throw missing(path);
|
||||||
|
}
|
||||||
String name = names[index];
|
String name = names[index];
|
||||||
Property<?> step = current.property(name);
|
Property<?> step = stepModel.property(name);
|
||||||
if (step == null || !current.isComplex(name)) {
|
if (step == null || !stepModel.isComplex(name)) {
|
||||||
throw missing(path);
|
throw missing(path);
|
||||||
}
|
}
|
||||||
Object nested = step.getValue();
|
Object nested = step.getValue();
|
||||||
@@ -126,11 +131,74 @@ final class DirectBindingApplier {
|
|||||||
current = child;
|
current = child;
|
||||||
}
|
}
|
||||||
String last = names[names.length - 1];
|
String last = names[names.length - 1];
|
||||||
Property<?> attribute = current.property(last);
|
Property<?> attribute = propertyOf(current, last);
|
||||||
if (attribute == null) {
|
if (attribute == null) {
|
||||||
throw missing(path);
|
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) {
|
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 {
|
public final class LoadedView {
|
||||||
|
|
||||||
private final Parent root;
|
private final Parent root;
|
||||||
private final ViewModel viewModel;
|
private final Object viewModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the result of one load.
|
* Creates the result of one load.
|
||||||
*
|
*
|
||||||
* @param root the scene-graph root
|
* @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.root = root;
|
||||||
this.viewModel = viewModel;
|
this.viewModel = viewModel;
|
||||||
}
|
}
|
||||||
@@ -33,11 +34,18 @@ public final class LoadedView {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the ViewModel the View is bound to.
|
* 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
|
* @return the ViewModel
|
||||||
|
* @throws IllegalStateException when the View is bound to a replacement
|
||||||
*/
|
*/
|
||||||
public ViewModel getViewModel() {
|
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}
|
* 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.
|
* is a string property holding the text of the latest Message.
|
||||||
*
|
*
|
||||||
* @param name the Attribute name, or {@code latestMessage}
|
* @param name the Attribute name, {@code latestMessage}, or a JavaFX property declared on
|
||||||
* @return the property, or {@code null} when that name is not a read-write Attribute
|
* the ViewModel
|
||||||
* and is not {@code latestMessage}
|
* @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) {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
* 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
|
* list: the Direct binding sets the control property to that
|
||||||
* {@link javafx.collections.ObservableList}, so an add or remove is visible on both sides.
|
* {@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,
|
* 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
|
* 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
|
* 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
|
* Attribute, with that Attribute's name as the source. A parent carries a copy with the source
|
||||||
* extended. It does not fail the load.
|
* extended. It does not fail the load.
|
||||||
@@ -40,7 +48,8 @@ public final class ViewLoader {
|
|||||||
* and injects its services. The Component document {@code ClassName.fxml}, in the same package,
|
* 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
|
* 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
|
* 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
|
* 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.
|
* ViewModel after the property is set and before those bindings run.
|
||||||
* Loading does not open a popup or fill a status bar. A Direct binding whose path is
|
* Loading does not open a popup or fill a status bar. A Direct binding whose path is
|
||||||
* {@code latestMessage} shows the latest Message text. {@link MessagePopup#showNewMessages}
|
* {@code latestMessage} shows the latest Message text. {@link MessagePopup#showNewMessages}
|
||||||
@@ -53,7 +62,8 @@ public final class ViewLoader {
|
|||||||
* @throws IllegalArgumentException when a Direct binding names a path that is not a read-write Attribute
|
* @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) {
|
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();
|
LoadSession session = LoadSession.open();
|
||||||
try {
|
try {
|
||||||
FXMLLoader loader = new FXMLLoader(fxml);
|
FXMLLoader loader = new FXMLLoader(fxml);
|
||||||
@@ -63,8 +73,15 @@ public final class ViewLoader {
|
|||||||
if (!(loaded instanceof Parent root)) {
|
if (!(loaded instanceof Parent root)) {
|
||||||
throw new IllegalArgumentException("View root must be a parent");
|
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()) {
|
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);
|
return new LoadedView(root, viewModel);
|
||||||
} catch (IOException failure) {
|
} catch (IOException failure) {
|
||||||
|
|||||||
@@ -91,10 +91,13 @@ public class ViewModel {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a ViewModel for one Model instance.
|
* 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
|
* @param model the Model instance
|
||||||
*/
|
*/
|
||||||
ViewModel(Object model) {
|
protected ViewModel(Object model) {
|
||||||
this.model = model;
|
this.model = model;
|
||||||
ViewModels.register(model, this);
|
ViewModels.register(model, this);
|
||||||
for (PropertyDescriptor descriptor : descriptors(model.getClass())) {
|
for (PropertyDescriptor descriptor : descriptors(model.getClass())) {
|
||||||
@@ -107,9 +110,9 @@ public class ViewModel {
|
|||||||
Class<?> type = descriptor.getPropertyType();
|
Class<?> type = descriptor.getPropertyType();
|
||||||
Object current = read(getter, name);
|
Object current = read(getter, name);
|
||||||
if (isComplex(type)) {
|
if (isComplex(type)) {
|
||||||
Property<?> property = new SimpleObjectProperty<>(
|
LazyComplex property = new LazyComplex(current);
|
||||||
current == null ? null : ViewModels.of(current));
|
|
||||||
watchComplex(property, setter, name);
|
watchComplex(property, setter, name);
|
||||||
|
property.attached();
|
||||||
properties.put(name, property);
|
properties.put(name, property);
|
||||||
complexAttributes.add(name);
|
complexAttributes.add(name);
|
||||||
} else {
|
} else {
|
||||||
@@ -130,15 +133,20 @@ public class ViewModel {
|
|||||||
* Model attributes. It is a string property. A Direct binding can read it. An in-place
|
* 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.
|
* update of that Message refreshes the text and does not replace the Message.
|
||||||
*
|
*
|
||||||
* @param name the Attribute name, or {@code latestMessage}
|
* @param name the Attribute name, {@code latestMessage}, or a JavaFX property declared on
|
||||||
* @return the property, or {@code null} when that name is not a read-write Attribute
|
* this ViewModel
|
||||||
* and is not {@code latestMessage}
|
* @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) {
|
public Property<?> property(String name) {
|
||||||
if ("latestMessage".equals(name)) {
|
if ("latestMessage".equals(name)) {
|
||||||
return latestMessageText;
|
return latestMessageText;
|
||||||
}
|
}
|
||||||
return properties.get(name);
|
Property<?> stored = properties.get(name);
|
||||||
|
if (stored != null) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
return declaredProperty(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,6 +180,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) {
|
private static List<PropertyDescriptor> descriptors(Class<?> type) {
|
||||||
try {
|
try {
|
||||||
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
|
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
|
||||||
@@ -287,10 +305,41 @@ public class ViewModel {
|
|||||||
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
|
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void watchComplex(Property<?> property, Method setter, String name) {
|
/**
|
||||||
if (property.getValue() instanceof ViewModel child) {
|
* Holds a Complex attribute and builds its ViewModel the first time the value is read.
|
||||||
attachChild(name, child);
|
* <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) -> {
|
property.addListener((observable, oldValue, newValue) -> {
|
||||||
if (oldValue != newValue) {
|
if (oldValue != newValue) {
|
||||||
detachChild(name);
|
detachChild(name);
|
||||||
@@ -299,6 +348,7 @@ public class ViewModel {
|
|||||||
write(setter, name, written);
|
write(setter, name, written);
|
||||||
if (oldValue != newValue && newValue instanceof ViewModel child) {
|
if (oldValue != newValue && newValue instanceof ViewModel child) {
|
||||||
attachChild(name, child);
|
attachChild(name, child);
|
||||||
|
realizeShared(child.model());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -309,6 +359,25 @@ public class ViewModel {
|
|||||||
* @param name the Attribute that leads to the child
|
* @param name the Attribute that leads to the child
|
||||||
* @param child the child ViewModel
|
* @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) {
|
private void attachChild(String name, ViewModel child) {
|
||||||
childMirrors.put(name, ChildMessageMirror.attach(this, name, 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;
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.lang.reflect.InvocationTargetException;
|
||||||
import java.util.IdentityHashMap;
|
import java.util.IdentityHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -10,24 +12,108 @@ import java.util.Map;
|
|||||||
*/
|
*/
|
||||||
final class ViewModels {
|
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() {
|
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
|
* @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) {
|
static Object of(Object model) {
|
||||||
ViewModel existing = BY_INSTANCE.get(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 (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 existing;
|
||||||
}
|
}
|
||||||
|
if (viewModelClass == null || viewModelClass == ViewModel.class) {
|
||||||
return new ViewModel(model);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records a ViewModel before its Attributes are walked, so a cycle reuses it.
|
* Records a ViewModel before its Attributes are walked, so a cycle reuses it.
|
||||||
@@ -35,7 +121,7 @@ final class ViewModels {
|
|||||||
* @param model the Model instance
|
* @param model the Model instance
|
||||||
* @param viewModel the ViewModel under construction
|
* @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);
|
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>
|
||||||
Reference in New Issue
Block a user