Merge simple-value lists into the shared View loader.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-30 17:43:42 +01:00
co-authored by Cursor
6 changed files with 205 additions and 7 deletions
@@ -2,6 +2,7 @@ package de.neitzel.fx.view;
import de.neitzel.injection.ApplicationContext;
import javafx.beans.property.Property;
import javafx.collections.ObservableList;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
@@ -44,6 +45,10 @@ final class DirectBindingApplier {
}
return;
}
if (attribute.getValue() instanceof ObservableList<?> values) {
assignList(controlProperty, values);
return;
}
String converterName = binding.getConverter();
if (converterName == null || converterName.isEmpty()) {
alignPlain(controlProperty, attribute, binding.getDirection());
@@ -282,6 +287,11 @@ final class DirectBindingApplier {
return null;
}
@SuppressWarnings("unchecked")
private static void assignList(Property<?> controlProperty, ObservableList<?> values) {
((Property<Object>) controlProperty).setValue(values);
}
@SuppressWarnings("unchecked")
private static void alignBoth(Property<?> controlProperty, Property<?> attribute) {
((Property<Object>) controlProperty).bindBidirectional((Property<Object>) attribute);
@@ -28,10 +28,12 @@ public final class ViewLoader {
* changes, and does not push later property changes to the control. A Path of one name is an
* Attribute of this View's ViewModel. Further names step into a nested ViewModel, and the
* direction applies to the last name. When a Complex attribute along the Path is null, the
* control and the Model keep their current values. 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 keeps the previous value and records one error
* Message. It does not fail the load.
* 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
* keeps the previous value and records one error Message. It does not fail the load.
*
* @param fxml the FXML document
* @param model the Model instance
@@ -14,6 +14,8 @@ import javafx.beans.property.SimpleLongProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -23,6 +25,11 @@ import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.WildcardType;
import java.time.temporal.TemporalAccessor;
import java.time.temporal.TemporalAmount;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
@@ -44,8 +51,12 @@ import java.util.Set;
* A Complex attribute, a read-write Attribute whose declared type itself has a read-write
* Attribute, stores the ViewModel for the nested Model instance, or null when the Attribute
* is null. Strings, numbers, booleans, enums, {@link Date}, {@link Calendar}, and {@code java.time}
* values stay simple even when that type has setters. A collection is not a Complex attribute.
* A cycle, or another Attribute that reaches the same instance, reuses that ViewModel.
* values stay simple even when that type has setters. A collection of Model instances is not a
* Complex attribute. A list, set, or collection of simple values is an object property whose
* value is an {@link ObservableList} of those values. When that attribute's current value is a
* list, the observable list is backed by it and stored on the entity when the setter accepts it,
* so both sides hold one list. A cycle, or another Attribute that reaches the same instance,
* reuses that ViewModel.
* The property is set from the Attribute when the ViewModel is created. A null number or
* boolean leaves the primitive property at its default. After that, a change of the property
* writes the Attribute. A Complex attribute writes the Model instance behind the ViewModel.
@@ -90,6 +101,9 @@ public class ViewModel {
properties.put(name, property);
complexAttributes.add(name);
} else {
if (isSimpleValueCollection(getter)) {
current = shareSimpleList(setter, name, current);
}
Property<?> property = createProperty(type, current);
watch(property, setter, name);
properties.put(name, property);
@@ -344,4 +358,62 @@ public class ViewModel {
LOGGER.error(message.getText());
}
}
private static boolean isSimpleValueCollection(Method getter) {
if (!Collection.class.isAssignableFrom(getter.getReturnType())) {
return false;
}
Class<?> element = elementType(getter.getGenericReturnType());
return element != null && isSimpleValue(element);
}
private static Class<?> elementType(Type type) {
if (!(type instanceof ParameterizedType parameterized) || parameterized.getActualTypeArguments().length != 1) {
return null;
}
return classOf(parameterized.getActualTypeArguments()[0]);
}
private static Class<?> classOf(Type type) {
if (type instanceof Class<?> raw) {
return raw;
}
if (type instanceof ParameterizedType parameterized && parameterized.getRawType() instanceof Class<?> raw) {
return raw;
}
if (type instanceof WildcardType wildcard && wildcard.getUpperBounds().length == 1) {
return classOf(wildcard.getUpperBounds()[0]);
}
return null;
}
private static boolean isSimpleValue(Class<?> type) {
if (type == String.class || type == Boolean.class || type == boolean.class || type.isEnum()) {
return true;
}
if (type.isPrimitive()) {
return type != char.class && type != void.class;
}
if (Number.class.isAssignableFrom(type)) {
return true;
}
if (Date.class.isAssignableFrom(type) || Calendar.class.isAssignableFrom(type)) {
return true;
}
return TemporalAccessor.class.isAssignableFrom(type) || TemporalAmount.class.isAssignableFrom(type);
}
@SuppressWarnings("unchecked")
private Object shareSimpleList(Method setter, String name, Object current) {
if (!(current instanceof Collection<?> collection)) {
return current;
}
ObservableList<Object> observable = current instanceof List<?> list
? FXCollections.observableList((List<Object>) list)
: FXCollections.observableArrayList((Collection<Object>) collection);
if (setter.getParameterTypes()[0].isAssignableFrom(observable.getClass())) {
write(setter, name, observable);
}
return observable;
}
}