First example to test component solution, renamed projects to neitzelfx.

This commit is contained in:
Konrad Neitzel
2025-03-23 07:02:22 +01:00
parent 371656572c
commit ba0800e49e
34 changed files with 9 additions and 9 deletions
+47
View File
@@ -0,0 +1,47 @@
<?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</groupId>
<artifactId>neitzelfx</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>neitzelfx-lib</artifactId>
<properties>
<!-- Application Properties -->
<link.name>${project.artifactId}</link.name>
<launcher>${project.artifactId}</launcher>
<appName>${project.artifactId}</appName>
<main.class>example.de.neitzel.neitzelfx.injectfx.Main</main.class>
<jar.filename>${project.artifactId}-${project.version}</jar.filename>
</properties>
<dependencies>
<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.neitzelfx.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.neitzelfx.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.neitzelfx.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,138 @@
package de.neitzel.neitzelfx.injectfx;
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 injectableComponents 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,245 @@
package de.neitzel.neitzelfx.injectfx;
import de.neitzel.neitzelfx.injectfx.annotation.FXMLComponent;
import org.reflections.Reflections;
import java.lang.reflect.Constructor;
import java.util.*;
import java.util.stream.Collectors;
/**
* InjectableComponents scans a package for classes annotated with {@link FXMLComponent}.
* It determines which components can be instantiated and manages type mappings for dependency injection.
*/
public class InjectableComponentScanner {
/** Set of all detected @FXMLComponent classes within the given package. */
private final Set<Class<?>> fxmlComponents = new HashSet<>();
/** Set of all superclasses and interfaces that are implemented by multiple @FXMLComponent classes. */
private final Set<Class<?>> notUniqueTypes = new HashSet<>();
/** Map of unique superclasses/interfaces to a single corresponding @FXMLComponent class. */
private final Map<Class<?>, Class<?>> uniqueTypeToComponent = new HashMap<>();
/** Map of instantiable @FXMLComponent classes and their corresponding interfaces/superclasses (if unique). */
private final Map<Class<?>, Class<?>> instantiableComponents = new HashMap<>();
/** List of error messages generated when resolving component instantiability. */
private final List<String> errors = new ArrayList<>();
/**
* Constructs an InjectableComponents instance and scans the given package for @FXMLComponent classes.
*
* @param basePackage The base package to scan for @FXMLComponent classes.
*/
public InjectableComponentScanner(String basePackage) {
scanForComponents(basePackage);
analyzeComponentTypes();
resolveInstantiableComponents();
}
/**
* Scans the specified package for classes annotated with @FXMLComponent.
*
* @param basePackage The package to scan.
*/
private void scanForComponents(String basePackage) {
Reflections reflections = new Reflections(basePackage);
fxmlComponents.addAll(reflections.getTypesAnnotatedWith(FXMLComponent.class));
}
/**
* Analyzes the collected @FXMLComponent classes to determine unique and non-unique superclasses/interfaces.
*/
private void analyzeComponentTypes() {
Map<Class<?>, List<Class<?>>> superTypesMap = new HashMap<>();
for (Class<?> component : fxmlComponents) {
Set<Class<?>> allSuperTypes = getAllSuperTypes(component);
for (Class<?> superType : allSuperTypes) {
superTypesMap.computeIfAbsent(superType, k -> new ArrayList<>()).add(component);
}
}
for (Map.Entry<Class<?>, List<Class<?>>> entry : superTypesMap.entrySet()) {
Class<?> superType = entry.getKey();
List<Class<?>> implementations = entry.getValue();
if (implementations.size() > 1) {
notUniqueTypes.add(superType);
} else {
uniqueTypeToComponent.put(superType, implementations.get(0));
}
}
}
/**
* Determines which @FXMLComponent classes can be instantiated based on known dependencies.
* It registers valid components and collects errors for components that cannot be instantiated.
*/
private void resolveInstantiableComponents() {
Set<Class<?>> resolved = new HashSet<>();
Set<Class<?>> unresolved = new HashSet<>(fxmlComponents);
Map<Class<?>, Class<?>> knownTypes = new HashMap<>();
boolean progress;
do {
progress = false;
Iterator<Class<?>> iterator = unresolved.iterator();
while (iterator.hasNext()) {
Class<?> component = iterator.next();
if (canInstantiate(component, knownTypes.keySet())) {
resolved.add(component);
registerComponentWithSuperTypes(component, knownTypes);
iterator.remove();
progress = true;
}
}
} while (progress);
instantiableComponents.putAll(knownTypes);
if (!unresolved.isEmpty()) {
collectInstantiationErrors(unresolved);
}
}
/**
* Registers a component along with its unique superclasses and interfaces in the known types map.
*
* @param component The component class.
* @param knownTypes The map of known instantiable types.
*/
private void registerComponentWithSuperTypes(Class<?> component, Map<Class<?>, Class<?>> knownTypes) {
knownTypes.put(component, component);
for (Class<?> superType : getAllSuperTypes(component)) {
if (!notUniqueTypes.contains(superType)) {
knownTypes.put(superType, component);
}
}
}
/**
* Checks whether a given @FXMLComponent class can be instantiated based on known dependencies.
*
* @param component The component class to check.
* @param knownTypes Set of known instantiable types.
* @return {@code true} if the component can be instantiated, otherwise {@code false}.
*/
private boolean canInstantiate(Class<?> component, Set<Class<?>> knownTypes) {
for (Constructor<?> constructor : component.getConstructors()) {
Class<?>[] paramTypes = constructor.getParameterTypes();
if (paramTypes.length == 0 || Arrays.stream(paramTypes).allMatch(knownTypes::contains)) {
return true;
}
}
return false;
}
/**
* Collects error messages for components that cannot be instantiated and adds them to the error list.
*
* @param unresolved Set of components that could not be instantiated.
*/
private void collectInstantiationErrors(Set<Class<?>> unresolved) {
for (Class<?> component : unresolved) {
StringBuilder errorMsg = new StringBuilder("Component cannot be instantiated: " + component.getName());
boolean possibleWithUniqueTypes = false;
for (Constructor<?> constructor : component.getConstructors()) {
Class<?>[] paramTypes = constructor.getParameterTypes();
List<Class<?>> problematicTypes = Arrays.stream(paramTypes)
.filter(t -> !instantiableComponents.containsKey(t) && !notUniqueTypes.contains(t))
.collect(Collectors.toList());
if (problematicTypes.isEmpty()) {
possibleWithUniqueTypes = true;
} else {
errorMsg.append("\n ➤ Requires unknown types: ").append(problematicTypes);
}
}
if (possibleWithUniqueTypes) {
errorMsg.append("\n ➤ Could be instantiated if multiple implementations of ")
.append("interfaces/superclasses were resolved uniquely: ")
.append(getConflictingTypes(component));
}
errors.add(errorMsg.toString());
}
}
/**
* Returns a comma-separated list of conflicting types that prevent instantiation.
*
* @param component The component class.
* @return A string listing the conflicting types.
*/
private String getConflictingTypes(Class<?> component) {
return Arrays.stream(component.getConstructors())
.flatMap(constructor -> Arrays.stream(constructor.getParameterTypes()))
.filter(notUniqueTypes::contains)
.map(Class::getName)
.collect(Collectors.joining(", "));
}
/**
* Retrieves all superclasses and interfaces of a given class.
*
* @param clazz The class to analyze.
* @return A set of all superclasses and interfaces of the given class.
*/
private Set<Class<?>> getAllSuperTypes(Class<?> clazz) {
Set<Class<?>> result = new HashSet<>();
Class<?> superClass = clazz.getSuperclass();
while (superClass != null && superClass != Object.class) {
result.add(superClass);
superClass = superClass.getSuperclass();
}
result.addAll(Arrays.asList(clazz.getInterfaces()));
return result;
}
/**
* Returns a map of instantiable @FXMLComponent classes and their associated interfaces/superclasses.
*
* @return A map of instantiable components.
*/
public Map<Class<?>, Class<?>> getInstantiableComponents() {
return instantiableComponents;
}
/**
* Returns the set of non-unique types (superclasses/interfaces with multiple implementations).
*
* @return A set of non-unique types.
*/
public Set<Class<?>> getNotUniqueTypes() {
return notUniqueTypes;
}
/**
* Returns the map of unique types to corresponding @FXMLComponent implementations.
*
* @return A map of unique types.
*/
public Map<Class<?>, Class<?>> getUniqueTypeToComponent() {
return uniqueTypeToComponent;
}
/**
* Returns a list of errors encountered during instantiation resolution.
*
* @return A list of error messages.
*/
public List<String> getErrors() {
return errors;
}
}
@@ -0,0 +1,43 @@
package de.neitzel.neitzelfx.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,44 @@
package de.neitzel.neitzelfx.injectfx;
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,11 @@
package de.neitzel.neitzelfx.injectfx.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface FXMLComponent {
}
@@ -0,0 +1,12 @@
package de.neitzel.neitzelfx.injectfx.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface FXMLConfig {
String value() default "";
}
@@ -0,0 +1,50 @@
package de.neitzel.neitzelfx.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.neitzelfx.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.neitzelfx.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.neitzelfx.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,50 @@
package de.neitzel.neitzelfx.injectfx;
import de.neitzel.neitzelfx.injectfx.testcomponents.test1ok.SuperClass;
import de.neitzel.neitzelfx.injectfx.testcomponents.test1ok.TestComponent1_1;
import de.neitzel.neitzelfx.injectfx.testcomponents.test1ok.TestInterface1_1;
import de.neitzel.neitzelfx.injectfx.testcomponents.test1ok.TestInterface1_2;
import de.neitzel.neitzelfx.injectfx.testcomponents.test1ok.sub.TestComponent1_2;
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.neitzelfx.injectfx.testcomponents.test1ok;
public class SuperClass {
}
@@ -0,0 +1,9 @@
package de.neitzel.neitzelfx.injectfx.testcomponents.test1ok;
import de.neitzel.neitzelfx.injectfx.annotation.FXMLComponent;
@FXMLComponent
public class TestComponent1_1 extends SuperClass implements TestInterface1_2 {
public TestComponent1_1() {
}
}
@@ -0,0 +1,4 @@
package de.neitzel.neitzelfx.injectfx.testcomponents.test1ok;
public interface TestInterface1_1 {
}
@@ -0,0 +1,4 @@
package de.neitzel.neitzelfx.injectfx.testcomponents.test1ok;
public interface TestInterface1_2 {
}
@@ -0,0 +1,12 @@
package de.neitzel.neitzelfx.injectfx.testcomponents.test1ok.sub;
import de.neitzel.neitzelfx.injectfx.annotation.FXMLComponent;
import de.neitzel.neitzelfx.injectfx.testcomponents.test1ok.SuperClass;
import de.neitzel.neitzelfx.injectfx.testcomponents.test1ok.TestInterface1_1;
import de.neitzel.neitzelfx.injectfx.testcomponents.test1ok.TestInterface1_2;
@FXMLComponent
public class TestComponent1_2 extends SuperClass implements TestInterface1_1, TestInterface1_2 {
public TestComponent1_2() {
}
}
@@ -0,0 +1,12 @@
package de.neitzel.neitzelfx.injectfx.testcomponents.test2fail;
import de.neitzel.neitzelfx.injectfx.annotation.FXMLComponent;
/**
* TestComponent1 that should fail.
*/
@FXMLComponent
public class TestComponent2_1 {
public TestComponent2_1(String test) {
}
}