diff --git a/fx/src/main/java/de/neitzel/fx/view/BindingList.java b/fx/src/main/java/de/neitzel/fx/view/BindingList.java index a3f7bb1..04f8f28 100644 --- a/fx/src/main/java/de/neitzel/fx/view/BindingList.java +++ b/fx/src/main/java/de/neitzel/fx/view/BindingList.java @@ -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 namespace, ViewModel viewModel, Object controller, Parent root, + void apply(Map namespace, Object viewModel, Object controller, Parent root, ApplicationContext context) { for (Object element : elements) { if (element instanceof DirectBinding directBinding) { diff --git a/fx/src/main/java/de/neitzel/fx/view/Components.java b/fx/src/main/java/de/neitzel/fx/view/Components.java index be7c6de..3813427 100644 --- a/fx/src/main/java/de/neitzel/fx/view/Components.java +++ b/fx/src/main/java/de/neitzel/fx/view/Components.java @@ -18,7 +18,8 @@ import java.util.Map; *

* 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 { *

* 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) { diff --git a/fx/src/main/java/de/neitzel/fx/view/ComputedBindingApplier.java b/fx/src/main/java/de/neitzel/fx/view/ComputedBindingApplier.java index b5b3d61..5455a4e 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ComputedBindingApplier.java +++ b/fx/src/main/java/de/neitzel/fx/view/ComputedBindingApplier.java @@ -35,7 +35,7 @@ final class ComputedBindingApplier { */ static void apply( Map 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 target) { diff --git a/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java index 9b730d1..26033cb 100644 --- a/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java +++ b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java @@ -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 namespace, ViewModel viewModel, DirectBinding binding, + static void apply(Map 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. + *

+ * 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) { diff --git a/fx/src/main/java/de/neitzel/fx/view/FxmlControllers.java b/fx/src/main/java/de/neitzel/fx/view/FxmlControllers.java new file mode 100644 index 0000000..3ae4b12 --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/FxmlControllers.java @@ -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); + } + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/LoadedView.java b/fx/src/main/java/de/neitzel/fx/view/LoadedView.java index e5050ea..5b90dbc 100644 --- a/fx/src/main/java/de/neitzel/fx/view/LoadedView.java +++ b/fx/src/main/java/de/neitzel/fx/view/LoadedView.java @@ -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. + *

+ * 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); } } diff --git a/fx/src/main/java/de/neitzel/fx/view/NamedProperties.java b/fx/src/main/java/de/neitzel/fx/view/NamedProperties.java new file mode 100644 index 0000000..adc65eb --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/NamedProperties.java @@ -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. + *

+ * 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); + } + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/RepeatApplier.java b/fx/src/main/java/de/neitzel/fx/view/RepeatApplier.java index 529b5dc..3e272be 100644 --- a/fx/src/main/java/de/neitzel/fx/view/RepeatApplier.java +++ b/fx/src/main/java/de/neitzel/fx/view/RepeatApplier.java @@ -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 namespace, ViewModel viewModel, Repeat repeat, + static void apply(Map 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 elements = (ObservableList) values; diff --git a/fx/src/main/java/de/neitzel/fx/view/SuppliedController.java b/fx/src/main/java/de/neitzel/fx/view/SuppliedController.java new file mode 100644 index 0000000..6716f4c --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/SuppliedController.java @@ -0,0 +1,31 @@ +package de.neitzel.fx.view; + +/** + * Controller used when a document names none. + *

+ * 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; + } +} diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java index 4b9a68b..02d7536 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java @@ -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) { diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java index 698fab2..db3ca30 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java @@ -92,10 +92,13 @@ public class ViewModel { /** * Builds a ViewModel for one Model instance. + *

+ * 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 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. + *

+ * 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 { + + 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. + *

+ * 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)); } diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModelClass.java b/fx/src/main/java/de/neitzel/fx/view/ViewModelClass.java new file mode 100644 index 0000000..2794aff --- /dev/null +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModelClass.java @@ -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. + *

+ * 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. + *

+ * 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(); +} diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModels.java b/fx/src/main/java/de/neitzel/fx/view/ViewModels.java index ff8bfbd..16c224d 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewModels.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModels.java @@ -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 BY_INSTANCE = new IdentityHashMap<>(); + private static final Map 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. + *

+ * 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); } } diff --git a/fx/src/test/java/de/neitzel/fx/view/ChooseControllerAndViewModelTest.java b/fx/src/test/java/de/neitzel/fx/view/ChooseControllerAndViewModelTest.java new file mode 100644 index 0000000..3f0ce75 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/ChooseControllerAndViewModelTest.java @@ -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 title = (Property) 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 badge = (Property) 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 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; + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/chosen/BadgeController.java b/fx/src/test/java/de/neitzel/fx/view/chosen/BadgeController.java new file mode 100644 index 0000000..41037b3 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/chosen/BadgeController.java @@ -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 { +} diff --git a/fx/src/test/java/de/neitzel/fx/view/chosen/BadgeViewModel.java b/fx/src/test/java/de/neitzel/fx/view/chosen/BadgeViewModel.java new file mode 100644 index 0000000..b888846 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/chosen/BadgeViewModel.java @@ -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; + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/chosen/CaptionAddressForm.java b/fx/src/test/java/de/neitzel/fx/view/chosen/CaptionAddressForm.java new file mode 100644 index 0000000..81803a8 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/chosen/CaptionAddressForm.java @@ -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 model = new SimpleObjectProperty<>(this, "model"); + + /** + * Returns the model property a parent View sets with a Direct binding. + * + * @return the model property + */ + public ObjectProperty 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); + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/chosen/DeskController.java b/fx/src/test/java/de/neitzel/fx/view/chosen/DeskController.java new file mode 100644 index 0000000..f49820f --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/chosen/DeskController.java @@ -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(); + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/chosen/DeskGreeting.java b/fx/src/test/java/de/neitzel/fx/view/chosen/DeskGreeting.java new file mode 100644 index 0000000..c4ccff8 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/chosen/DeskGreeting.java @@ -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"; + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/chosen/HandedNameController.java b/fx/src/test/java/de/neitzel/fx/view/chosen/HandedNameController.java new file mode 100644 index 0000000..2b851a5 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/chosen/HandedNameController.java @@ -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; + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/chosen/StreetTitleViewModel.java b/fx/src/test/java/de/neitzel/fx/view/chosen/StreetTitleViewModel.java new file mode 100644 index 0000000..8474e25 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/chosen/StreetTitleViewModel.java @@ -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; + } +} diff --git a/fx/src/test/java/de/neitzel/fx/view/chosen/TitledController.java b/fx/src/test/java/de/neitzel/fx/view/chosen/TitledController.java new file mode 100644 index 0000000..5a4f4a9 --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/chosen/TitledController.java @@ -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 { +} diff --git a/fx/src/test/java/de/neitzel/fx/view/chosen/TitledViewModel.java b/fx/src/test/java/de/neitzel/fx/view/chosen/TitledViewModel.java new file mode 100644 index 0000000..0d7156c --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/chosen/TitledViewModel.java @@ -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; + } +} diff --git a/fx/src/test/resources/de/neitzel/fx/view/badge-name.fxml b/fx/src/test/resources/de/neitzel/fx/view/badge-name.fxml new file mode 100644 index 0000000..97709a3 --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/badge-name.fxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/caption-address.fxml b/fx/src/test/resources/de/neitzel/fx/view/caption-address.fxml new file mode 100644 index 0000000..508282c --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/caption-address.fxml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/chosen/CaptionAddressForm.fxml b/fx/src/test/resources/de/neitzel/fx/view/chosen/CaptionAddressForm.fxml new file mode 100644 index 0000000..7a6444d --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/chosen/CaptionAddressForm.fxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/desk-greeting.fxml b/fx/src/test/resources/de/neitzel/fx/view/desk-greeting.fxml new file mode 100644 index 0000000..5d774a9 --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/desk-greeting.fxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/handed-name.fxml b/fx/src/test/resources/de/neitzel/fx/view/handed-name.fxml new file mode 100644 index 0000000..2bf9e39 --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/handed-name.fxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/named-person.fxml b/fx/src/test/resources/de/neitzel/fx/view/named-person.fxml new file mode 100644 index 0000000..2dc613e --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/named-person.fxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/fx/src/test/resources/de/neitzel/fx/view/titled-name.fxml b/fx/src/test/resources/de/neitzel/fx/view/titled-name.fxml new file mode 100644 index 0000000..589cae4 --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/titled-name.fxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/injection/src/main/java/de/neitzel/injection/ApplicationContext.java b/injection/src/main/java/de/neitzel/injection/ApplicationContext.java index 149e2af..b0197bb 100644 --- a/injection/src/main/java/de/neitzel/injection/ApplicationContext.java +++ b/injection/src/main/java/de/neitzel/injection/ApplicationContext.java @@ -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. *

@@ -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;