Let an application show a list of simple values in a control and on the entity as one shared list.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package de.neitzel.fx.view;
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
import javafx.beans.property.Property;
|
import javafx.beans.property.Property;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
|
||||||
import java.lang.reflect.InvocationTargetException;
|
import java.lang.reflect.InvocationTargetException;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
@@ -32,6 +33,10 @@ final class DirectBindingApplier {
|
|||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"No read-write attribute for path \"" + binding.getPath() + "\"");
|
"No read-write attribute for path \"" + binding.getPath() + "\"");
|
||||||
}
|
}
|
||||||
|
if (attribute.getValue() instanceof ObservableList<?> values) {
|
||||||
|
assignList(controlProperty, values);
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (binding.getDirection()) {
|
switch (binding.getDirection()) {
|
||||||
case "both" -> alignBoth(controlProperty, attribute);
|
case "both" -> alignBoth(controlProperty, attribute);
|
||||||
case "view" -> alignTowardView(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<Object>) controlProperty).setValue(values);
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private static void alignBoth(Property<?> controlProperty, Property<?> attribute) {
|
private static void alignBoth(Property<?> controlProperty, Property<?> attribute) {
|
||||||
((Property<Object>) controlProperty).bindBidirectional((Property<Object>) attribute);
|
((Property<Object>) controlProperty).bindBidirectional((Property<Object>) attribute);
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ public final class ViewLoader {
|
|||||||
* aligned, so editing the control writes the Attribute immediately. Direction {@code view}
|
* 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.
|
* 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
|
* 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
|
* changes, and does not push later property changes to the control. A collection of simple
|
||||||
* read-write Attribute fails the load. The context is used when the document names a Controller.
|
* 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 fxml the FXML document
|
||||||
* @param model the Model instance
|
* @param model the Model instance
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import javafx.beans.property.SimpleIntegerProperty;
|
|||||||
import javafx.beans.property.SimpleLongProperty;
|
import javafx.beans.property.SimpleLongProperty;
|
||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
import javafx.beans.property.SimpleStringProperty;
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
|
||||||
import java.beans.BeanInfo;
|
import java.beans.BeanInfo;
|
||||||
import java.beans.IntrospectionException;
|
import java.beans.IntrospectionException;
|
||||||
@@ -20,6 +22,14 @@ import java.beans.Introspector;
|
|||||||
import java.beans.PropertyDescriptor;
|
import java.beans.PropertyDescriptor;
|
||||||
import java.lang.reflect.InvocationTargetException;
|
import java.lang.reflect.InvocationTargetException;
|
||||||
import java.lang.reflect.Method;
|
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.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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
|
* 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,
|
* 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
|
* 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
|
* 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
|
* 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.
|
* writes the Attribute. A call to the entity's setter does not update the property.
|
||||||
@@ -54,7 +68,11 @@ public class ViewModel {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String name = descriptor.getName();
|
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);
|
watch(property, setter, name);
|
||||||
properties.put(name, property);
|
properties.put(name, property);
|
||||||
}
|
}
|
||||||
@@ -160,4 +178,62 @@ public class ViewModel {
|
|||||||
throw new IllegalStateException("Could not write attribute " + name, failure);
|
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<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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String> tags = (ListView<String>) 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<Throwable> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.neitzel.fx.view.fixture;
|
package de.neitzel.fx.view.fixture;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordinary model used by view-loading tests.
|
* Ordinary model used by view-loading tests.
|
||||||
*/
|
*/
|
||||||
@@ -7,6 +9,7 @@ public class Person {
|
|||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
private Integer age;
|
private Integer age;
|
||||||
|
private List<String> tags;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a person with the given name.
|
* Creates a person with the given name.
|
||||||
@@ -61,4 +64,22 @@ public class Person {
|
|||||||
public void setAge(Integer age) {
|
public void setAge(Integer age) {
|
||||||
this.age = age;
|
this.age = age;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tags. The value may be null.
|
||||||
|
*
|
||||||
|
* @return the tags
|
||||||
|
*/
|
||||||
|
public List<String> getTags() {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the tags.
|
||||||
|
*
|
||||||
|
* @param tags the tags
|
||||||
|
*/
|
||||||
|
public void setTags(List<String> tags) {
|
||||||
|
this.tags = tags;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import javafx.scene.control.ListView?>
|
||||||
|
<?import javafx.scene.layout.VBox?>
|
||||||
|
<?import de.neitzel.fx.view.BindingList?>
|
||||||
|
<?import de.neitzel.fx.view.DirectBinding?>
|
||||||
|
|
||||||
|
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||||
|
<fx:define>
|
||||||
|
<BindingList>
|
||||||
|
<DirectBinding control="tags" property="items" path="tags" direction="both"/>
|
||||||
|
</BindingList>
|
||||||
|
</fx:define>
|
||||||
|
<ListView fx:id="tags" id="tags"/>
|
||||||
|
</VBox>
|
||||||
Reference in New Issue
Block a user