Besseres, universelles Auteilen.
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
== Komponenten
|
||||
|
||||
=== injectfx
|
||||
|
||||
`injectfx` adressiert das Problem, dass man bei bestimmten Controllern gerne Konstruktorparameter übergeben möchte. Möchte man die Controller-Klasse direkt im Code verwenden, ist das relativ einfach. Wenn man jedoch Code, Controller und FXML strikt voneinander trennen möchte, wird die Umsetzung deutlich komplexer.
|
||||
|
||||
Ein möglicher Ansatz besteht in einer universellen `ControllerFactory` (InjectingControllerFactory), der man potenzielle Parameter übergeben kann. Zur Instanzierung prüft die Factory die vorhandenen Konstruktoren und gleicht sie mit den verfügbaren Parametern ab, um anschließend den passenden Konstruktor aufzurufen.
|
||||
|
||||
Dieses Prinzip lässt sich noch erweitern:
|
||||
- Ein universeller `FXMLLoader` kann den Code vereinfachen, da keine separate InjectingControllerFactory mehr erstellt und zugewiesen werden muss.
|
||||
- Anstatt Parameter manuell bereitzustellen, könnten Klassen durch Markierungen als potenzielle Parameter erkannt werden – vergleichbar mit bekannten Injection-Frameworks wie z. B. Spring Boot.
|
||||
|
||||
=== component
|
||||
|
||||
Ähnlich wie in anderen UI-Frameworks (z. B. React) versuche ich, ein komponentenbasiertes Modell zu etablieren, um datengetriebene Oberflächen schnell und modular zusammenstellen zu können.
|
||||
|
||||
Die Grundidee ist, mit möglichst geringem Aufwand Kombinationen aus Datenmodell und FXML zu erzeugen. So lässt sich z. B. eine FXML-Datei definieren, die eine Adresse darstellt – inklusive Felder und der zugehörigen Bindings. Möchte man nun einen Datensatz mit einer Adresse anzeigen, kann für das Adressfeld eine Pane erzeugt werden, die sowohl das Binding auf die Adresse als auch den Verweis auf die FXML-Datei enthält.
|
||||
|
||||
Dabei wird das MVVM-Pattern verwendet: Ausgehend vom Model wird ein passendes ViewModel automatisch generiert.
|
||||
|
||||
Die Bindings werden allerdings zunehmend komplex, sobald nicht ausschließlich einfache Typen wie `StringProperty` verwendet werden.
|
||||
|
||||
=== mvvm
|
||||
|
||||
Ein Ansatz, das MVVM-Pattern in JavaFX möglichst einfach zugänglich zu machen.
|
||||
|
||||
Die zentralen Herausforderungen, die hier gelöst werden sollen, sind:
|
||||
- die automatische Erstellung eines Controllers, der das Binding zwischen View und ViewModel übernimmt
|
||||
- Unterstützung bei der Erzeugung von ViewModel-Elementen auf Basis vorhandener Model-Klassen
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>de.neitzel.lib</groupId>
|
||||
<artifactId>neitzellib</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>fx</artifactId>
|
||||
|
||||
<properties>
|
||||
<!-- Application Properties -->
|
||||
<link.name>${project.artifactId}</link.name>
|
||||
<launcher>${project.artifactId}</launcher>
|
||||
<appName>${project.artifactId}</appName>
|
||||
<main.class>example.de.neitzel.fx.injectfx.Main</main.class>
|
||||
<jar.filename>${project.artifactId}-${project.version}</jar.filename>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>de.neitzel.lib</groupId>
|
||||
<artifactId>inject</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.reflections</groupId>
|
||||
<artifactId>reflections</artifactId>
|
||||
<version>${reflections.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>${jar.filename}</finalName>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-pmd-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,108 @@
|
||||
package de.neitzel.fx.component;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
import java.lang.reflect.*;
|
||||
import java.util.*;
|
||||
|
||||
public class AutoViewModel<T> {
|
||||
|
||||
private final T model;
|
||||
private final Map<String, Property<?>> properties = new HashMap<>();
|
||||
|
||||
public AutoViewModel(T model) {
|
||||
this.model = model;
|
||||
initProperties();
|
||||
}
|
||||
|
||||
private void initProperties() {
|
||||
for (Method getter : model.getClass().getMethods()) {
|
||||
if (isGetter(getter)) {
|
||||
String fieldName = getFieldName(getter);
|
||||
Object value = invokeGetter(getter);
|
||||
|
||||
Property<?> prop = toProperty(value);
|
||||
properties.put(fieldName, prop);
|
||||
|
||||
// Bind ViewModel → Model
|
||||
prop.addListener((obs, oldVal, newVal) -> {
|
||||
Method setter = findSetterFor(model.getClass(), fieldName, newVal != null ? newVal.getClass() : null);
|
||||
if (setter != null) {
|
||||
try {
|
||||
setter.invoke(model, newVal);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Property<?> getProperty(String name) {
|
||||
return properties.get(name);
|
||||
}
|
||||
|
||||
public T getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
// ========== Hilfsmethoden ==========
|
||||
|
||||
private boolean isGetter(Method method) {
|
||||
return Modifier.isPublic(method.getModifiers())
|
||||
&& method.getParameterCount() == 0
|
||||
&& !method.getReturnType().equals(void.class)
|
||||
&& (method.getName().startsWith("get") || method.getName().startsWith("is"));
|
||||
}
|
||||
|
||||
private String getFieldName(Method method) {
|
||||
String name = method.getName();
|
||||
if (name.startsWith("get")) {
|
||||
return decapitalize(name.substring(3));
|
||||
} else if (name.startsWith("is")) {
|
||||
return decapitalize(name.substring(2));
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private String decapitalize(String str) {
|
||||
if (str == null || str.isEmpty()) return str;
|
||||
return str.substring(0, 1).toLowerCase() + str.substring(1);
|
||||
}
|
||||
|
||||
private Object invokeGetter(Method method) {
|
||||
try {
|
||||
return method.invoke(model);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Method findSetterFor(Class<?> clazz, String fieldName, Class<?> valueType) {
|
||||
String setterName = "set" + capitalize(fieldName);
|
||||
for (Method m : clazz.getMethods()) {
|
||||
if (m.getName().equals(setterName) && m.getParameterCount() == 1) {
|
||||
if (valueType == null || m.getParameterTypes()[0].isAssignableFrom(valueType)) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String capitalize(String str) {
|
||||
if (str == null || str.isEmpty()) return str;
|
||||
return str.substring(0, 1).toUpperCase() + str.substring(1);
|
||||
}
|
||||
|
||||
private Property<?> toProperty(Object value) {
|
||||
if (value instanceof String s) return new SimpleStringProperty(s);
|
||||
if (value instanceof Integer i) return new SimpleIntegerProperty(i);
|
||||
if (value instanceof Boolean b) return new SimpleBooleanProperty(b);
|
||||
if (value instanceof Double d) return new SimpleDoubleProperty(d);
|
||||
if (value instanceof Float f) return new SimpleFloatProperty(f);
|
||||
if (value instanceof Long l) return new SimpleLongProperty(l);
|
||||
return new SimpleObjectProperty<>(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.neitzel.fx.component;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Generic controller used by the ComponentLoader to bind FXML views
|
||||
* to automatically generated ViewModels based on POJO models.
|
||||
* <p>
|
||||
* This controller provides access to the {@link AutoViewModel}
|
||||
* which contains JavaFX properties derived from the model.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class ComponentController {
|
||||
|
||||
/**
|
||||
* The automatically generated ViewModel that holds JavaFX properties
|
||||
* for all model fields. It is used to create bindings between
|
||||
* the view and the model.
|
||||
*/
|
||||
@Getter
|
||||
private final AutoViewModel<?> viewModel;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package de.neitzel.fx.component;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.layout.Pane;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* ComponentLoader is responsible for loading JavaFX FXML components and binding
|
||||
* them to automatically generated ViewModels based on simple POJO models.
|
||||
* <p>
|
||||
* It parses custom NFX attributes in FXML to bind UI elements to properties in the ViewModel,
|
||||
* and supports recursive loading of subcomponents.
|
||||
*/
|
||||
public class ComponentLoader {
|
||||
private Map<String, Map<String, String>> nfxBindingMap = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Loads an FXML file and binds its elements to a generated ViewModel
|
||||
* based on the given POJO model.
|
||||
*
|
||||
* @param model the data model (POJO) to bind to the UI
|
||||
* @param fxmlPath the path to the FXML file
|
||||
* @return the root JavaFX node loaded from FXML
|
||||
*/
|
||||
public Parent load(Object model, String fxmlPath) {
|
||||
try {
|
||||
AutoViewModel<?> viewModel = new AutoViewModel<>(model);
|
||||
String cleanedUri = preprocessNfxAttributes(fxmlPath);
|
||||
FXMLLoader loader = new FXMLLoader(new URL(cleanedUri));
|
||||
loader.setControllerFactory(type -> new ComponentController(viewModel));
|
||||
Parent root = loader.load();
|
||||
processNfxBindings(root, viewModel, loader);
|
||||
return root;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("unable to load fxml: " + fxmlPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes all UI elements for NFX binding attributes and applies
|
||||
* the appropriate bindings to the ViewModel.
|
||||
*
|
||||
* @param root the root node of the loaded FXML hierarchy
|
||||
* @param viewModel the generated ViewModel to bind against
|
||||
*/
|
||||
private void processNfxBindings(Parent root, AutoViewModel<?> viewModel, FXMLLoader loader) {
|
||||
walkNodes(root, node -> {
|
||||
var nfx = lookupNfxAttributes(node, loader);
|
||||
String target = nfx.get("nfx:target");
|
||||
String direction = nfx.get("nfx:direction");
|
||||
String source = nfx.get("nfx:source");
|
||||
|
||||
if (target != null && direction != null) {
|
||||
Property<?> vmProp = viewModel.getProperty(target);
|
||||
bindNodeToProperty(node, vmProp, direction);
|
||||
}
|
||||
|
||||
if (source != null) {
|
||||
Object subModel = ((Property<?>) viewModel.getProperty(target)).getValue();
|
||||
Parent subComponent = load(subModel, source);
|
||||
if (node instanceof Pane pane) {
|
||||
pane.getChildren().setAll(subComponent);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks all nodes in the scene graph starting from the root,
|
||||
* applying the given consumer to each node.
|
||||
*
|
||||
* @param root the starting node
|
||||
* @param consumer the consumer to apply to each node
|
||||
*/
|
||||
private void walkNodes(Parent root, java.util.function.Consumer<javafx.scene.Node> consumer) {
|
||||
consumer.accept(root);
|
||||
if (root instanceof Pane pane) {
|
||||
for (javafx.scene.Node child : pane.getChildren()) {
|
||||
if (child instanceof Parent p) {
|
||||
walkNodes(p, consumer);
|
||||
} else {
|
||||
consumer.accept(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts custom NFX attributes from a node's properties map.
|
||||
* These attributes are expected to be in the format "nfx:..." and hold string values.
|
||||
*
|
||||
* @param node the node to inspect
|
||||
* @return a map of NFX attribute names to values
|
||||
*/
|
||||
private Map<String, String> extractNfxAttributes(javafx.scene.Node node) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
node.getProperties().forEach((k, v) -> {
|
||||
if (k instanceof String key && key.startsWith("nfx:") && v instanceof String value) {
|
||||
result.put(key, value);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a JavaFX UI control to a ViewModel property according to the specified direction.
|
||||
*
|
||||
* @param node the UI node (e.g., TextField)
|
||||
* @param vmProp the ViewModel property to bind to
|
||||
* @param direction the direction of the binding (e.g., "bidirectional", "read")
|
||||
*/
|
||||
private void bindNodeToProperty(javafx.scene.Node node, Property<?> vmProp, String direction) {
|
||||
if (node instanceof javafx.scene.control.TextField tf && vmProp instanceof javafx.beans.property.StringProperty sp) {
|
||||
if ("bidirectional".equalsIgnoreCase(direction)) {
|
||||
tf.textProperty().bindBidirectional(sp);
|
||||
} else if ("read".equalsIgnoreCase(direction)) {
|
||||
tf.textProperty().bind(sp);
|
||||
}
|
||||
}
|
||||
// Additional control types (e.g., CheckBox, ComboBox) can be added here
|
||||
}
|
||||
|
||||
private String preprocessNfxAttributes(String fxmlPath) {
|
||||
try {
|
||||
nfxBindingMap.clear();
|
||||
var factory = javax.xml.parsers.DocumentBuilderFactory.newInstance();
|
||||
var builder = factory.newDocumentBuilder();
|
||||
var doc = builder.parse(getClass().getResourceAsStream(fxmlPath));
|
||||
var all = doc.getElementsByTagName("*");
|
||||
|
||||
int autoId = 0;
|
||||
for (int i = 0; i < all.getLength(); i++) {
|
||||
var el = (org.w3c.dom.Element) all.item(i);
|
||||
Map<String, String> nfxAttrs = new HashMap<>();
|
||||
var attrs = el.getAttributes();
|
||||
|
||||
List<String> toRemove = new ArrayList<>();
|
||||
for (int j = 0; j < attrs.getLength(); j++) {
|
||||
var attr = (org.w3c.dom.Attr) attrs.item(j);
|
||||
if (attr.getName().startsWith("nfx:")) {
|
||||
nfxAttrs.put(attr.getName(), attr.getValue());
|
||||
toRemove.add(attr.getName());
|
||||
}
|
||||
}
|
||||
|
||||
if (!nfxAttrs.isEmpty()) {
|
||||
String fxid = el.getAttribute("fx:id");
|
||||
if (fxid == null || fxid.isBlank()) {
|
||||
fxid = "auto_id_" + (++autoId);
|
||||
el.setAttribute("fx:id", fxid);
|
||||
}
|
||||
nfxBindingMap.put(fxid, nfxAttrs);
|
||||
toRemove.forEach(el::removeAttribute);
|
||||
}
|
||||
}
|
||||
|
||||
// Speichere das bereinigte Dokument als temporäre Datei
|
||||
File tempFile = File.createTempFile("cleaned_fxml", ".fxml");
|
||||
tempFile.deleteOnExit();
|
||||
var transformer = javax.xml.transform.TransformerFactory.newInstance().newTransformer();
|
||||
transformer.setOutputProperty(javax.xml.transform.OutputKeys.INDENT, "yes");
|
||||
transformer.transform(new javax.xml.transform.dom.DOMSource(doc),
|
||||
new javax.xml.transform.stream.StreamResult(tempFile));
|
||||
|
||||
return tempFile.toURI().toString();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Preprocessing failed for: " + fxmlPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> lookupNfxAttributes(javafx.scene.Node node, FXMLLoader loader) {
|
||||
String fxid = loader.getNamespace().entrySet().stream()
|
||||
.filter(e -> e.getValue() == node)
|
||||
.map(Map.Entry::getKey)
|
||||
.findFirst().orElse(null);
|
||||
if (fxid == null) return Map.of();
|
||||
return nfxBindingMap.getOrDefault(fxid, Map.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package de.neitzel.fx.injectfx;
|
||||
|
||||
import de.neitzel.inject.InjectableComponentScanner;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Manages the creation and storage of singleton instances for all @FXMLComponent classes.
|
||||
* It ensures that each component is instantiated only once and resolves dependencies recursively.
|
||||
*/
|
||||
public class FXMLComponentInstances {
|
||||
|
||||
/** Map holding instances of all @FXMLComponent classes, indexed by class and its unique superclasses/interfaces. */
|
||||
private final Map<Class<?>, Object> instanceMap = new HashMap<>();
|
||||
|
||||
/** The InjectableComponents instance that provides information about instantiable components. */
|
||||
private final InjectableComponentScanner injectableScanner;
|
||||
|
||||
/**
|
||||
* Constructs an FXMLComponentInstances manager and initializes all component instances.
|
||||
*
|
||||
* @param injectableScanner The InjectableComponents instance containing resolved component types.
|
||||
*/
|
||||
public FXMLComponentInstances(InjectableComponentScanner injectableScanner) {
|
||||
this.injectableScanner = injectableScanner;
|
||||
createAllInstances();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates instances for all registered @FXMLComponent classes.
|
||||
*/
|
||||
private void createAllInstances() {
|
||||
for (Class<?> componentClass : injectableScanner.getInstantiableComponents().keySet()) {
|
||||
getInstance(componentClass); // Ensures each component is instantiated once
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an instance of a given component class, creating it if necessary.
|
||||
*
|
||||
* @param componentClass The class for which an instance is needed.
|
||||
* @return An instance of the requested class.
|
||||
*/
|
||||
public Object getInstance(Class<?> componentClass) {
|
||||
if (instanceMap.containsKey(componentClass)) {
|
||||
return instanceMap.get(componentClass);
|
||||
}
|
||||
|
||||
Class<?> concreteClass = injectableScanner.getInstantiableComponents().get(componentClass);
|
||||
if (concreteClass == null) {
|
||||
throw new IllegalStateException("No concrete implementation found for: " + componentClass.getName());
|
||||
}
|
||||
|
||||
Object instance = createInstance(concreteClass);
|
||||
registerInstance(concreteClass, instance);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of the given class by selecting the best constructor.
|
||||
*
|
||||
* @param concreteClass The class to instantiate.
|
||||
* @return A new instance of the class.
|
||||
*/
|
||||
private Object createInstance(Class<?> concreteClass) {
|
||||
try {
|
||||
Constructor<?> bestConstructor = findBestConstructor(concreteClass);
|
||||
if (bestConstructor == null) {
|
||||
throw new IllegalStateException("No suitable constructor found for: " + concreteClass.getName());
|
||||
}
|
||||
|
||||
Class<?>[] paramTypes = bestConstructor.getParameterTypes();
|
||||
Object[] params = Arrays.stream(paramTypes)
|
||||
.map(this::getInstance) // Recursively resolve dependencies
|
||||
.toArray();
|
||||
|
||||
return bestConstructor.newInstance(params);
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to instantiate " + concreteClass.getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the best constructor for a given class by checking which one has resolvable parameters.
|
||||
*
|
||||
* @param concreteClass The class to analyze.
|
||||
* @return The best constructor or {@code null} if none can be used.
|
||||
*/
|
||||
private Constructor<?> findBestConstructor(Class<?> concreteClass) {
|
||||
Constructor<?>[] constructors = concreteClass.getConstructors();
|
||||
|
||||
// Prefer constructors with all parameters resolvable
|
||||
for (Constructor<?> constructor : constructors) {
|
||||
if (canUseConstructor(constructor)) {
|
||||
return constructor;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given constructor can be used by ensuring all parameters are known.
|
||||
*
|
||||
* @param constructor The constructor to check.
|
||||
* @return {@code true} if all parameters can be resolved, otherwise {@code false}.
|
||||
*/
|
||||
private boolean canUseConstructor(Constructor<?> constructor) {
|
||||
return Arrays.stream(constructor.getParameterTypes())
|
||||
.allMatch(injectableScanner.getInstantiableComponents()::containsKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an instance in the map for all unique interfaces and superclasses it implements.
|
||||
*
|
||||
* @param concreteClass The concrete component class.
|
||||
* @param instance The instance to store.
|
||||
*/
|
||||
private void registerInstance(Class<?> concreteClass, Object instance) {
|
||||
instanceMap.put(concreteClass, instance);
|
||||
|
||||
for (Map.Entry<Class<?>, Class<?>> entry : injectableScanner.getInstantiableComponents().entrySet()) {
|
||||
if (entry.getValue().equals(concreteClass)) {
|
||||
instanceMap.put(entry.getKey(), instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the instance map containing all component instances.
|
||||
*
|
||||
* @return A map of class types to their instances.
|
||||
*/
|
||||
public Map<Class<?>, Object> getInstanceMap() {
|
||||
return instanceMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.neitzel.fx.injectfx;
|
||||
|
||||
import javafx.util.Callback;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class InjectingControllerFactory implements Callback<Class<?>, Object> {
|
||||
private final Map<Class<?>, Object> parameterMap = new HashMap<>();
|
||||
|
||||
public void addInjectingData(Class<?> clazz, Object object) {
|
||||
parameterMap.put(clazz, object);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object call(Class<?> controllerClass) {
|
||||
try {
|
||||
Optional<Constructor<?>> bestConstructor = Stream.of(controllerClass.getConstructors())
|
||||
.filter(constructor -> canBeInstantiated(constructor, parameterMap))
|
||||
.findFirst();
|
||||
|
||||
if (bestConstructor.isPresent()) {
|
||||
Constructor<?> constructor = bestConstructor.get();
|
||||
Object[] parameters = Stream.of(constructor.getParameterTypes())
|
||||
.map(parameterMap::get)
|
||||
.toArray();
|
||||
|
||||
return constructor.newInstance(parameters);
|
||||
} else {
|
||||
throw new IllegalStateException("Kein passender Konstruktor gefunden für " + controllerClass.getName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Fehler beim Erstellen der Controller-Instanz für " + controllerClass.getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canBeInstantiated(Constructor<?> constructor, Map<Class<?>, Object> parameterMap) {
|
||||
return Stream.of(constructor.getParameterTypes()).allMatch(parameterMap::containsKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.neitzel.fx.injectfx;
|
||||
|
||||
import de.neitzel.inject.InjectableComponentScanner;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
|
||||
@Slf4j
|
||||
public class InjectingFXMLLoader {
|
||||
|
||||
private final InjectingControllerFactory controllerFactory;
|
||||
|
||||
public InjectingFXMLLoader() {
|
||||
controllerFactory = new InjectingControllerFactory();
|
||||
}
|
||||
|
||||
public InjectingFXMLLoader(InjectingControllerFactory controllerFactory) {
|
||||
this.controllerFactory = controllerFactory;
|
||||
}
|
||||
|
||||
public InjectingFXMLLoader(String packageName) {
|
||||
controllerFactory = new InjectingControllerFactory();
|
||||
InjectableComponentScanner scanner = new InjectableComponentScanner(packageName);
|
||||
FXMLComponentInstances instances = new FXMLComponentInstances(scanner);
|
||||
addInjectingData(instances);
|
||||
}
|
||||
|
||||
private void addInjectingData(FXMLComponentInstances instances) {
|
||||
for (var clazz: instances.getInstanceMap().keySet()) {
|
||||
addInjectingData(clazz, instances.getInstance(clazz));
|
||||
}
|
||||
}
|
||||
|
||||
public void addInjectingData(Class<?> clazz, Object object) {
|
||||
controllerFactory.addInjectingData(clazz, object);
|
||||
}
|
||||
|
||||
public <T> T load(URL url) throws IOException {
|
||||
FXMLLoader loader = new FXMLLoader(url);
|
||||
loader.setControllerFactory(controllerFactory);
|
||||
return loader.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.neitzel.fx.mvvm;
|
||||
|
||||
/**
|
||||
* Enum representing the direction of data binding between a JavaFX control and a ViewModel property.
|
||||
* - READ: one-way binding from ViewModel to Control.
|
||||
* - WRITE: one-way binding from Control to ViewModel.
|
||||
* - BIDIRECTIONAL: two-way binding between Control and ViewModel.
|
||||
*/
|
||||
public enum BindDirection {
|
||||
/**
|
||||
* One-way binding from the ViewModel to the JavaFX control.
|
||||
* The control reflects changes in the ViewModel but does not update it.
|
||||
*/
|
||||
READ,
|
||||
|
||||
/**
|
||||
* One-way binding from the JavaFX control to the ViewModel.
|
||||
* The ViewModel is updated when the control changes, but not vice versa.
|
||||
*/
|
||||
WRITE,
|
||||
|
||||
/**
|
||||
* Two-way binding between the JavaFX control and the ViewModel.
|
||||
* Changes in either are reflected in the other.
|
||||
*/
|
||||
BIDIRECTIONAL;
|
||||
|
||||
/**
|
||||
* Parses a string to determine the corresponding BindDirection.
|
||||
* Defaults to BIDIRECTIONAL if the input is null or empty.
|
||||
*
|
||||
* @param value the string representation of the direction
|
||||
* @return the corresponding BindDirection enum constant
|
||||
* @throws IllegalArgumentException if the string does not match any valid direction
|
||||
*/
|
||||
public static BindDirection fromString(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return BIDIRECTIONAL;
|
||||
}
|
||||
|
||||
for (BindDirection direction : BindDirection.values()) {
|
||||
if (direction.name().equalsIgnoreCase(value)) {
|
||||
return direction;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Not a valid direction: " + value);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package de.neitzel.fx.mvvm;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.control.TextInputControl;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
/**
|
||||
* Custom FXMLLoader that binds JavaFX controls to a GenericViewModel using metadata from FXML.
|
||||
* It supports automatic binding setup based on properties defined in the FXML's node properties.
|
||||
*
|
||||
* @param <T> the type of the model used in the ViewModel
|
||||
*/
|
||||
public class BindingAwareFXMLLoader<T> {
|
||||
|
||||
/**
|
||||
* The model instance used to construct the GenericViewModel.
|
||||
*/
|
||||
private final T model;
|
||||
|
||||
/**
|
||||
* Loads an FXML file and performs automatic binding setup using the GenericViewModel.
|
||||
*
|
||||
* @param fxml the input stream of the FXML file
|
||||
* @return the root node of the loaded scene graph
|
||||
* @throws Exception if loading the FXML or binding fails
|
||||
*/
|
||||
public Parent load(InputStream fxml) throws Exception {
|
||||
FXMLLoader loader = new FXMLLoader();
|
||||
loader.setControllerFactory(param -> {
|
||||
GenericViewController<T> controller = new GenericViewController<>();
|
||||
controller.setModel(model);
|
||||
return controller;
|
||||
});
|
||||
|
||||
Parent root = loader.load(fxml);
|
||||
|
||||
GenericViewController<T> controller = loader.getController();
|
||||
GenericViewModel<T> viewModel = controller.getViewModel();
|
||||
|
||||
// Traverse Nodes and evaluate binding info
|
||||
bindNodesRecursively(root, viewModel);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively traverses the scene graph and binds controls to properties
|
||||
* in the ViewModel based on custom metadata in the node properties map.
|
||||
*
|
||||
* @param node the current node to inspect
|
||||
* @param viewModel the ViewModel holding the properties
|
||||
*/
|
||||
private void bindNodesRecursively(Node node, GenericViewModel<T> viewModel) {
|
||||
if (node instanceof Parent parent) {
|
||||
for (Node child : parent.getChildrenUnmodifiable()) {
|
||||
bindNodesRecursively(child, viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
Object userData = node.getProperties().get("bind:property");
|
||||
if (userData instanceof String propertyName) {
|
||||
BindDirection direction = getDirection(node);
|
||||
|
||||
if (node instanceof TextInputControl control) {
|
||||
Property<String> prop = viewModel.property(StringProperty.class, propertyName);
|
||||
bind(control.textProperty(), prop, direction);
|
||||
} else if (node instanceof javafx.scene.control.Label label) {
|
||||
Property<String> prop = viewModel.property(StringProperty.class, propertyName);
|
||||
bind(label.textProperty(), prop, direction);
|
||||
} else if (node instanceof javafx.scene.control.CheckBox checkBox) {
|
||||
Property<Boolean> prop = viewModel.property(javafx.beans.property.BooleanProperty.class, propertyName);
|
||||
bind(checkBox.selectedProperty(), prop, direction);
|
||||
} else if (node instanceof javafx.scene.control.Slider slider) {
|
||||
Property<Number> prop = viewModel.property(javafx.beans.property.DoubleProperty.class, propertyName);
|
||||
bind(slider.valueProperty(), prop, direction);
|
||||
} else if (node instanceof javafx.scene.control.DatePicker datePicker) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Property<java.time.LocalDate> prop = (Property<java.time.LocalDate>) viewModel.property(javafx.beans.property.ObjectProperty.class, propertyName);
|
||||
bind(datePicker.valueProperty(), prop, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds two JavaFX properties according to the specified binding direction.
|
||||
*
|
||||
* @param controlProperty the property of the control (e.g. TextField.textProperty)
|
||||
* @param modelProperty the property from the ViewModel
|
||||
* @param direction the direction of the binding
|
||||
* @param <V> the value type of the property
|
||||
*/
|
||||
private <V> void bind(Property<V> controlProperty, Property<V> modelProperty, BindDirection direction) {
|
||||
switch (direction) {
|
||||
case BIDIRECTIONAL -> controlProperty.bindBidirectional(modelProperty);
|
||||
case READ -> controlProperty.bind(modelProperty);
|
||||
case WRITE -> modelProperty.bind(controlProperty);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the binding direction for a node based on its "bind:direction" property.
|
||||
*
|
||||
* @param node the node whose direction property is read
|
||||
* @return the BindDirection specified or a default
|
||||
*/
|
||||
private BindDirection getDirection(final Node node) {
|
||||
return BindDirection.fromString((String) node.getProperties().get("bind:direction"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.neitzel.fx.mvvm;
|
||||
|
||||
import javafx.fxml.Initializable;
|
||||
import java.net.URL;
|
||||
import java.util.ResourceBundle;
|
||||
|
||||
/**
|
||||
* A generic JavaFX controller that holds a GenericViewModel for the given model type.
|
||||
* Intended for use with a custom FXMLLoader that performs automatic property bindings.
|
||||
*
|
||||
* @param <T> the type of the model
|
||||
*/
|
||||
public class GenericViewController<T> implements Initializable {
|
||||
/**
|
||||
* The GenericViewModel instance wrapping the underlying model.
|
||||
*/
|
||||
private GenericViewModel<T> viewModel;
|
||||
|
||||
/**
|
||||
* Sets the model for this controller by wrapping it in a GenericViewModel.
|
||||
*
|
||||
* @param model the model to be used by the ViewModel
|
||||
*/
|
||||
public void setModel(T model) {
|
||||
this.viewModel = new GenericViewModel<>(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the original model instance from the ViewModel.
|
||||
*
|
||||
* @return the model instance
|
||||
*/
|
||||
public T getModel() {
|
||||
return viewModel.getModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard JavaFX initialize method. No-op, as actual binding is done externally via FXMLLoader extension.
|
||||
*
|
||||
* @param location the location used to resolve relative paths for the root object, or null if unknown
|
||||
* @param resources the resources used to localize the root object, or null if not localized
|
||||
*/
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
// Real binding happens in FXMLLoader extension
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the GenericViewModel used by this controller.
|
||||
*
|
||||
* @return the GenericViewModel instance
|
||||
*/
|
||||
public GenericViewModel<T> getViewModel() {
|
||||
return viewModel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package de.neitzel.fx.mvvm;
|
||||
|
||||
import javafx.beans.property.*;
|
||||
|
||||
import java.beans.BeanInfo;
|
||||
import java.beans.IntrospectionException;
|
||||
import java.beans.Introspector;
|
||||
import java.beans.PropertyDescriptor;
|
||||
import java.lang.reflect.*;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* A generic ViewModel for JavaFX using reflection to automatically create properties for model attributes.
|
||||
* Supports binding between JavaFX controls and the model via JavaFX properties.
|
||||
*
|
||||
* @param <T> the type of the underlying model
|
||||
*/
|
||||
public class GenericViewModel<T> {
|
||||
/**
|
||||
* The original model instance.
|
||||
*/
|
||||
private final T model;
|
||||
|
||||
/**
|
||||
* A map holding JavaFX properties corresponding to the model's fields.
|
||||
*/
|
||||
private final Map<String, Property<?>> properties = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Constructs a GenericViewModel for the given model instance.
|
||||
*
|
||||
* @param model the model to wrap
|
||||
*/
|
||||
public GenericViewModel(T model) {
|
||||
this.model = model;
|
||||
initProperties();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes JavaFX properties for all readable and writable model fields using reflection.
|
||||
*/
|
||||
private void initProperties() {
|
||||
Class<?> clazz = model.getClass();
|
||||
for (PropertyDescriptor pd : getPropertyDescriptors(clazz)) {
|
||||
Method getter = pd.getReadMethod();
|
||||
Method setter = pd.getWriteMethod();
|
||||
String name = pd.getName();
|
||||
|
||||
if (getter != null && setter != null) {
|
||||
Class<?> propertyType = pd.getPropertyType();
|
||||
try {
|
||||
Object value = getter.invoke(model);
|
||||
Property<?> property = createJavaFXProperty(propertyType, value);
|
||||
properties.put(name, property);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to initialize property: " + name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all readable/writable property descriptors of the given class.
|
||||
*
|
||||
* @param clazz the class to introspect
|
||||
* @return a list of property descriptors
|
||||
*/
|
||||
private List<PropertyDescriptor> getPropertyDescriptors(Class<?> clazz) {
|
||||
try {
|
||||
BeanInfo info = Introspector.getBeanInfo(clazz, Object.class);
|
||||
return Arrays.asList(info.getPropertyDescriptors());
|
||||
} catch (IntrospectionException e) {
|
||||
throw new RuntimeException("Cannot introspect model class: " + clazz, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an appropriate JavaFX Property instance for the given type and initial value.
|
||||
*
|
||||
* @param type the type of the property (e.g., String.class, int.class)
|
||||
* @param initialValue the initial value of the property
|
||||
* @return a new JavaFX Property instance
|
||||
*/
|
||||
private Property<?> createJavaFXProperty(Class<?> type, Object initialValue) {
|
||||
if (type == String.class) return new SimpleStringProperty((String) initialValue);
|
||||
if (type == int.class || type == Integer.class) return new SimpleIntegerProperty((Integer) initialValue);
|
||||
if (type == double.class || type == Double.class) return new SimpleDoubleProperty((Double) initialValue);
|
||||
if (type == boolean.class || type == Boolean.class) return new SimpleBooleanProperty((Boolean) initialValue);
|
||||
if (type == long.class || type == Long.class) return new SimpleLongProperty((Long) initialValue);
|
||||
// Fallback for unsupported types:
|
||||
return new SimpleObjectProperty<>(initialValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the JavaFX property associated with the given name and type.
|
||||
*
|
||||
* @param propertyClass the expected property type (e.g., StringProperty.class)
|
||||
* @param name the name of the model field
|
||||
* @param <P> the type of the Property
|
||||
* @return the corresponding JavaFX property
|
||||
* @throws IllegalArgumentException if the property doesn't exist or the type mismatches
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <P extends Property<?>> P property(Class<P> propertyClass, String name) {
|
||||
Property<?> prop = properties.get(name);
|
||||
if (prop == null) {
|
||||
throw new IllegalArgumentException("No property found for name: " + name);
|
||||
}
|
||||
if (!propertyClass.isInstance(prop)) {
|
||||
throw new IllegalArgumentException("Property type mismatch: expected " + propertyClass.getSimpleName());
|
||||
}
|
||||
return (P) prop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the current values of all properties back into the model using their respective setters.
|
||||
*/
|
||||
public void save() {
|
||||
for (PropertyDescriptor pd : getPropertyDescriptors(model.getClass())) {
|
||||
String name = pd.getName();
|
||||
Method setter = pd.getWriteMethod();
|
||||
if (setter != null && properties.containsKey(name)) {
|
||||
try {
|
||||
Object value = properties.get(name).getValue();
|
||||
setter.invoke(model, value);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to save property: " + name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the wrapped model instance.
|
||||
*
|
||||
* @return the model
|
||||
*/
|
||||
public T getModel() {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.neitzel.fx.injectfx;
|
||||
|
||||
import de.neitzel.fx.injectfx.testcomponents.test1ok.SuperClass;
|
||||
import de.neitzel.fx.injectfx.testcomponents.test1ok.TestComponent1_1;
|
||||
import de.neitzel.fx.injectfx.testcomponents.test1ok.TestInterface1_1;
|
||||
import de.neitzel.fx.injectfx.testcomponents.test1ok.TestInterface1_2;
|
||||
import de.neitzel.fx.injectfx.testcomponents.test1ok.sub.TestComponent1_2;
|
||||
import de.neitzel.inject.InjectableComponentScanner;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class InjectableComponentScannerTest {
|
||||
|
||||
/**
|
||||
* Tests loading of multiple FXMLComponents including sub packages.
|
||||
*/
|
||||
@Test
|
||||
void testLoadComponents() {
|
||||
InjectableComponentScanner scanner = new InjectableComponentScanner("de.neitzel.injectfx.testcomponents.test1ok");
|
||||
var instantiableComponents = scanner.getInstantiableComponents();
|
||||
var nonUniqueTypes = scanner.getNotUniqueTypes();
|
||||
|
||||
assertAll(
|
||||
() -> assertNotNull(instantiableComponents),
|
||||
() -> assertEquals(3, instantiableComponents.size()),
|
||||
() -> assertTrue(instantiableComponents.containsKey(TestComponent1_1.class)),
|
||||
() -> assertTrue(instantiableComponents.containsKey(TestComponent1_2.class)),
|
||||
() -> assertTrue(instantiableComponents.containsKey(TestInterface1_1.class)),
|
||||
() -> assertTrue(nonUniqueTypes.contains(SuperClass.class)),
|
||||
() -> assertTrue(nonUniqueTypes.contains(TestInterface1_2.class)),
|
||||
() -> assertTrue(scanner.getErrors().isEmpty())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests failing to load a FXMLComponent which has an unknwown parameter.
|
||||
*/
|
||||
@Test
|
||||
void testComponentsFailWithUnknownParameters() {
|
||||
InjectableComponentScanner scanner = new InjectableComponentScanner("de.neitzel.injectfx.testcomponents.test2fail");
|
||||
var instantiableComponents = scanner.getInstantiableComponents();
|
||||
var nonUniqueTypes = scanner.getNotUniqueTypes();
|
||||
|
||||
assertAll(
|
||||
() -> assertNotNull(instantiableComponents),
|
||||
() -> assertEquals(0, instantiableComponents.size()),
|
||||
() -> assertFalse(scanner.getErrors().isEmpty())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package de.neitzel.fx.injectfx.testcomponents.test1ok;
|
||||
|
||||
public class SuperClass {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.neitzel.fx.injectfx.testcomponents.test1ok;
|
||||
|
||||
import de.neitzel.inject.annotation.Component;
|
||||
|
||||
@Component
|
||||
public class TestComponent1_1 extends SuperClass implements TestInterface1_2 {
|
||||
public TestComponent1_1() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package de.neitzel.fx.injectfx.testcomponents.test1ok;
|
||||
|
||||
public interface TestInterface1_1 {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package de.neitzel.fx.injectfx.testcomponents.test1ok;
|
||||
|
||||
public interface TestInterface1_2 {
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package de.neitzel.fx.injectfx.testcomponents.test1ok.sub;
|
||||
|
||||
import de.neitzel.fx.injectfx.testcomponents.test1ok.SuperClass;
|
||||
import de.neitzel.fx.injectfx.testcomponents.test1ok.TestInterface1_1;
|
||||
import de.neitzel.fx.injectfx.testcomponents.test1ok.TestInterface1_2;
|
||||
import de.neitzel.inject.annotation.Component;
|
||||
|
||||
@Component
|
||||
public class TestComponent1_2 extends SuperClass implements TestInterface1_1, TestInterface1_2 {
|
||||
public TestComponent1_2() {
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package de.neitzel.fx.injectfx.testcomponents.test2fail;
|
||||
|
||||
import de.neitzel.inject.annotation.Component;
|
||||
|
||||
/**
|
||||
* TestComponent1 that should fail.
|
||||
*/
|
||||
@Component
|
||||
public class TestComponent2_1 {
|
||||
public TestComponent2_1(String test) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user