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 87d9493..aa87df9 100644 --- a/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java +++ b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java @@ -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) controlProperty).setValue(values); + } + @SuppressWarnings("unchecked") private static void alignBoth(Property controlProperty, Property attribute) { ((Property) controlProperty).bindBidirectional((Property) attribute); 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 f808a05..7a1d168 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java @@ -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 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 28d7e5a..fbcbd94 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java @@ -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 observable = current instanceof List list + ? FXCollections.observableList((List) list) + : FXCollections.observableArrayList((Collection) collection); + if (setter.getParameterTypes()[0].isAssignableFrom(observable.getClass())) { + write(setter, name, observable); + } + return observable; + } } diff --git a/fx/src/test/java/de/neitzel/fx/view/ShowAListOfSimpleValuesTest.java b/fx/src/test/java/de/neitzel/fx/view/ShowAListOfSimpleValuesTest.java new file mode 100644 index 0000000..291f6ac --- /dev/null +++ b/fx/src/test/java/de/neitzel/fx/view/ShowAListOfSimpleValuesTest.java @@ -0,0 +1,99 @@ +package de.neitzel.fx.view; + +import de.neitzel.fx.view.fixture.Person; +import de.neitzel.injection.ApplicationContext; +import javafx.application.Platform; +import javafx.scene.control.ListView; +import javafx.scene.layout.VBox; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +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.assertSame; +import static org.junit.jupiter.api.Assertions.fail; + +class ShowAListOfSimpleValuesTest { + + @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 listViewAndPersonShareTheTagList() throws Exception { + onFx(() -> { + Person person = new Person("Ada"); + person.setTags(new ArrayList<>(List.of("red", "blue"))); + URL fxml = ShowAListOfSimpleValuesTest.class.getResource("tags-both-ways.fxml"); + assertNotNull(fxml); + ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture"); + LoadedView loaded = ViewLoader.load(fxml, person, context); + VBox root = (VBox) loaded.getRoot(); + assertEquals(1, root.getChildren().size()); + @SuppressWarnings("unchecked") + ListView tags = (ListView) root.lookup("#tags"); + assertEquals(List.of("red", "blue"), List.copyOf(tags.getItems())); + assertEquals("red", tags.getItems().get(0)); + assertEquals("blue", tags.getItems().get(1)); + assertSame(person.getTags(), tags.getItems()); + tags.getItems().add("green"); + assertEquals(List.of("red", "blue", "green"), List.copyOf(person.getTags())); + person.getTags().add("gold"); + assertEquals(List.of("red", "blue", "green", "gold"), List.copyOf(tags.getItems())); + tags.getItems().remove("blue"); + assertEquals(List.of("red", "green", "gold"), List.copyOf(person.getTags())); + }); + } + + 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/fixture/Person.java b/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java index 1b7cc7d..36a416e 100644 --- a/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java +++ b/fx/src/test/java/de/neitzel/fx/view/fixture/Person.java @@ -240,7 +240,7 @@ public class Person { } /** - * Returns the tags. + * Returns the tags. The value may be null. * * @return the tags */ diff --git a/fx/src/test/resources/de/neitzel/fx/view/tags-both-ways.fxml b/fx/src/test/resources/de/neitzel/fx/view/tags-both-ways.fxml new file mode 100644 index 0000000..c43e38a --- /dev/null +++ b/fx/src/test/resources/de/neitzel/fx/view/tags-both-ways.fxml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + +