From aab55cbde32f287dbe7cda5665a83a2c95c408a4 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Fri, 30 Oct 2026 17:28:18 +0100 Subject: [PATCH] Let an application show a list of simple values in a control and on the entity as one shared list. Co-authored-by: Cursor --- .../neitzel/fx/view/DirectBindingApplier.java | 10 ++ .../java/de/neitzel/fx/view/ViewLoader.java | 7 +- .../java/de/neitzel/fx/view/ViewModel.java | 80 ++++++++++++++- .../fx/view/ShowAListOfSimpleValuesTest.java | 99 +++++++++++++++++++ .../de/neitzel/fx/view/fixture/Person.java | 21 ++++ .../de/neitzel/fx/view/tags-both-ways.fxml | 15 +++ 6 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 fx/src/test/java/de/neitzel/fx/view/ShowAListOfSimpleValuesTest.java create mode 100644 fx/src/test/resources/de/neitzel/fx/view/tags-both-ways.fxml 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 73d170c..276d283 100644 --- a/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java +++ b/fx/src/main/java/de/neitzel/fx/view/DirectBindingApplier.java @@ -1,6 +1,7 @@ package de.neitzel.fx.view; import javafx.beans.property.Property; +import javafx.collections.ObservableList; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -32,6 +33,10 @@ final class DirectBindingApplier { throw new IllegalArgumentException( "No read-write attribute for path \"" + binding.getPath() + "\""); } + if (attribute.getValue() instanceof ObservableList values) { + assignList(controlProperty, values); + return; + } switch (binding.getDirection()) { case "both" -> alignBoth(controlProperty, attribute); case "view" -> alignTowardView(controlProperty, attribute); @@ -57,6 +62,11 @@ final class DirectBindingApplier { } } + @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 4189a94..9a322b7 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java @@ -23,8 +23,11 @@ public final class ViewLoader { * aligned, so editing the control writes the Attribute immediately. Direction {@code view} * shows the Attribute and follows later property changes without writing the Attribute back. * Direction {@code model} shows the current value, writes the Attribute when the control - * changes, and does not push later property changes to the control. A path that is not a - * read-write Attribute fails the load. The context is used when the document names a Controller. + * changes, and does not push later property changes to the control. 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 is used when the + * document names a Controller. * * @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 1e108a1..3786341 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java @@ -13,6 +13,8 @@ import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleLongProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import java.beans.BeanInfo; import java.beans.IntrospectionException; @@ -20,6 +22,14 @@ 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.Calendar; +import java.util.Collection; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -30,7 +40,11 @@ import java.util.Map; * Each read-write Attribute that is not declared on {@link Object} is stored as a JavaFX * property under the Attribute name. The property class follows the Attribute's declared type, * including when the current value is null. String, integer, long, float, double, and boolean - * attributes use the matching JavaFX property. Any other declared type is an object property. + * attributes use the matching JavaFX property. A list, set, or collection of simple values is an + * object property whose value is an {@link ObservableList} of those values. Simple values are + * strings, numbers, booleans, enums, and date/time 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. Any other declared type is an object property. * 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 call to the entity's setter does not update the property. @@ -54,7 +68,11 @@ public class ViewModel { continue; } String name = descriptor.getName(); - Property property = createProperty(descriptor.getPropertyType(), read(getter, name)); + Object current = read(getter, name); + if (isSimpleValueCollection(getter)) { + current = shareSimpleList(setter, name, current); + } + Property property = createProperty(descriptor.getPropertyType(), current); watch(property, setter, name); properties.put(name, property); } @@ -160,4 +178,62 @@ public class ViewModel { throw new IllegalStateException("Could not write attribute " + name, failure); } } + + 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 12cfd17..178b5dd 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 @@ -1,5 +1,7 @@ package de.neitzel.fx.view.fixture; +import java.util.List; + /** * Ordinary model used by view-loading tests. */ @@ -7,6 +9,7 @@ public class Person { private String name; private Integer age; + private List tags; /** * Creates a person with the given name. @@ -61,4 +64,22 @@ public class Person { public void setAge(Integer age) { this.age = age; } + + /** + * Returns the tags. The value may be null. + * + * @return the tags + */ + public List getTags() { + return tags; + } + + /** + * Sets the tags. + * + * @param tags the tags + */ + public void setTags(List tags) { + this.tags = 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 @@ + + + + + + + + + + + + + + +