Let an application load a View for an ordinary Model and edit one Attribute in place.

The control shows the current value, typing writes it immediately, and a later entity setter no longer moves the control.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-30 17:17:44 +01:00
co-authored by Cursor
parent 1b1be747bb
commit 99886efa8b
13 changed files with 840 additions and 0 deletions
@@ -0,0 +1,49 @@
package de.neitzel.fx.view;
import javafx.beans.DefaultProperty;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* The list of binding elements declared inside {@code fx:define}.
* <p>
* The list is not a scene-graph child. FXML adds each {@link DirectBinding} child to
* {@link #getElements()}.
*/
@DefaultProperty("elements")
public class BindingList {
private final List<DirectBinding> elements = new ArrayList<>();
/**
* Creates a list and registers it with the load in progress.
*/
public BindingList() {
LoadSession.register(this);
}
/**
* Returns the Direct bindings in this list.
* <p>
* FXML appends child elements to the returned list.
*
* @return the mutable list of Direct bindings
*/
public List<DirectBinding> getElements() {
return elements;
}
/**
* Applies every Direct binding in this list.
*
* @param namespace objects addressed by {@code fx:id}
* @param viewModel the ViewModel for this View
*/
void apply(Map<String, Object> namespace, ViewModel viewModel) {
for (DirectBinding binding : elements) {
DirectBindingApplier.apply(namespace, viewModel, binding);
}
}
}
@@ -0,0 +1,88 @@
package de.neitzel.fx.view;
/**
* A Direct binding declared in FXML.
* <p>
* The element names the control by {@code fx:id}, the control property, the Path, and the
* direction. A Path of one name is that Attribute of this View's ViewModel. Direction values
* are {@code view}, {@code model}, and {@code both}.
*/
public class DirectBinding {
private String control;
private String property;
private String path;
private String direction;
/**
* Sets the {@code fx:id} of the control.
*
* @param control the control id
*/
public void setControl(String control) {
this.control = control;
}
/**
* Sets the control property name, such as {@code text}.
*
* @param property the property name
*/
public void setProperty(String property) {
this.property = property;
}
/**
* Sets the Path. One name is an Attribute of this View's ViewModel.
*
* @param path the path
*/
public void setPath(String path) {
this.path = path;
}
/**
* Sets the direction. Accepted values are {@code view}, {@code model}, and {@code both}.
*
* @param direction the direction value
*/
public void setDirection(String direction) {
this.direction = direction;
}
/**
* Returns the {@code fx:id} of the control.
*
* @return the control id
*/
public String getControl() {
return control;
}
/**
* Returns the control property name.
*
* @return the property name
*/
public String getProperty() {
return property;
}
/**
* Returns the Path.
*
* @return the path
*/
public String getPath() {
return path;
}
/**
* Returns the direction value.
*
* @return {@code view}, {@code model}, or {@code both}
*/
public String getDirection() {
return direction;
}
}
@@ -0,0 +1,80 @@
package de.neitzel.fx.view;
import javafx.beans.property.Property;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;
/**
* Connects one Direct binding to a control property and a ViewModel Attribute.
*/
final class DirectBindingApplier {
private DirectBindingApplier() {
}
/**
* Applies one Direct binding.
*
* @param namespace objects addressed by {@code fx:id}
* @param viewModel the ViewModel for this View
* @param binding the declaration
*/
static void apply(Map<String, Object> namespace, ViewModel viewModel, DirectBinding binding) {
Object control = namespace.get(binding.getControl());
if (control == null) {
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
}
Property<?> controlProperty = controlProperty(control, binding.getProperty());
Property<?> attribute = viewModel.property(binding.getPath());
if (attribute == null) {
throw new IllegalArgumentException(
"No read-write attribute for path \"" + binding.getPath() + "\"");
}
switch (binding.getDirection()) {
case "both" -> alignBoth(controlProperty, attribute);
case "view" -> alignTowardView(controlProperty, attribute);
case "model" -> alignTowardModel(controlProperty, attribute);
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
}
}
private static Property<?> controlProperty(Object control, String propertyName) {
String methodName = propertyName + "Property";
try {
Method method = control.getClass().getMethod(methodName);
Object value = method.invoke(control);
if (!(value instanceof Property<?> property)) {
throw new IllegalArgumentException(methodName + " did not return a property");
}
return property;
} catch (NoSuchMethodException failure) {
throw new IllegalArgumentException(
"No property " + propertyName + " on " + control.getClass().getName(), failure);
} catch (IllegalAccessException | InvocationTargetException failure) {
throw new IllegalStateException("Could not read property " + propertyName, failure);
}
}
@SuppressWarnings("unchecked")
private static void alignBoth(Property<?> controlProperty, Property<?> attribute) {
((Property<Object>) controlProperty).bindBidirectional((Property<Object>) attribute);
}
@SuppressWarnings("unchecked")
private static void alignTowardView(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
Property<Object> source = (Property<Object>) attribute;
control.setValue(source.getValue());
source.addListener((observable, oldValue, newValue) -> control.setValue(newValue));
}
@SuppressWarnings("unchecked")
private static void alignTowardModel(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
Property<Object> target = (Property<Object>) attribute;
control.setValue(target.getValue());
control.addListener((observable, oldValue, newValue) -> target.setValue(newValue));
}
}
@@ -0,0 +1,60 @@
package de.neitzel.fx.view;
import java.util.ArrayList;
import java.util.List;
/**
* Holds the binding lists instantiated while one View is loading.
* <p>
* FXML objects declared in {@code fx:define} are not scene-graph children and have no
* {@code fx:id} in the accepted document shape, so each {@link BindingList} registers
* itself here from its constructor.
*/
final class LoadSession {
private static final ThreadLocal<LoadSession> CURRENT = new ThreadLocal<>();
private final List<BindingList> bindingLists = new ArrayList<>();
private LoadSession() {
}
/**
* Starts a session on this thread.
*
* @return the open session
*/
static LoadSession open() {
LoadSession session = new LoadSession();
CURRENT.set(session);
return session;
}
/**
* Drops the session for this thread.
*/
static void close() {
CURRENT.remove();
}
/**
* Registers a binding list created while a session is open.
*
* @param bindingList the list FXML just constructed
*/
static void register(BindingList bindingList) {
LoadSession session = CURRENT.get();
if (session != null) {
session.bindingLists.add(bindingList);
}
}
/**
* Returns the lists registered during this session, in document order.
*
* @return the binding lists
*/
List<BindingList> bindingLists() {
return bindingLists;
}
}
@@ -0,0 +1,54 @@
package de.neitzel.fx.view;
import javafx.beans.property.Property;
import javafx.scene.Parent;
/**
* The scene-graph root and ViewModel produced by loading a View for a Model instance.
*/
public final class LoadedView {
private final Parent root;
private final ViewModel viewModel;
/**
* Creates the result of one load.
*
* @param root the scene-graph root
* @param viewModel the ViewModel built for the Model instance
*/
LoadedView(Parent root, ViewModel viewModel) {
this.root = root;
this.viewModel = viewModel;
}
/**
* Returns the scene-graph root.
*
* @return the root
*/
public Parent getRoot() {
return root;
}
/**
* Returns the ViewModel the View is bound to.
*
* @return the ViewModel
*/
public ViewModel getViewModel() {
return viewModel;
}
/**
* Returns the JavaFX property for an Attribute name.
* <p>
* The property's class follows the Attribute's declared type.
*
* @param name the Attribute name
* @return the property, or {@code null} when that name is not a read-write Attribute
*/
public Property<?> property(String name) {
return viewModel.property(name);
}
}
@@ -0,0 +1,59 @@
package de.neitzel.fx.view;
import de.neitzel.injection.ApplicationContext;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import java.io.IOException;
import java.net.URL;
/**
* Loads a View for one Model instance.
*/
public final class ViewLoader {
private ViewLoader() {
}
/**
* Loads a View for one Model instance.
* <p>
* Builds a ViewModel for {@code model}, loads {@code fxml}, and applies Direct bindings
* declared in {@code fx:define}. Direction {@code both} keeps the control and the Attribute
* 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.
*
* @param fxml the FXML document
* @param model the Model instance
* @param context the application context
* @return the loaded View
* @throws IllegalArgumentException when a Direct binding names a path that is not a read-write Attribute
*/
public static LoadedView load(URL fxml, Object model, ApplicationContext context) {
ViewModel viewModel = new ViewModel(model);
LoadSession session = LoadSession.open();
try {
FXMLLoader loader = new FXMLLoader(fxml);
loader.setControllerFactory(type -> controller(context, type));
Object loaded = loader.load();
if (!(loaded instanceof Parent root)) {
throw new IllegalArgumentException("View root must be a parent");
}
for (BindingList bindingList : session.bindingLists()) {
bindingList.apply(loader.getNamespace(), viewModel);
}
return new LoadedView(root, viewModel);
} catch (IOException failure) {
throw new IllegalStateException("Could not load view " + fxml, failure);
} finally {
LoadSession.close();
}
}
private static Object controller(ApplicationContext context, Class<?> type) {
return context.getComponent(type.asSubclass(Object.class));
}
}
@@ -0,0 +1,163 @@
package de.neitzel.fx.view;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.FloatProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.LongProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleFloatProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleLongProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* The object a View binds to, built at runtime for one Model instance.
* <p>
* 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.
* 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.
*/
public class ViewModel {
private final Object model;
private final Map<String, Property<?>> properties = new HashMap<>();
/**
* Builds a ViewModel for one Model instance.
*
* @param model the Model instance
*/
ViewModel(Object model) {
this.model = model;
for (PropertyDescriptor descriptor : descriptors(model.getClass())) {
Method getter = descriptor.getReadMethod();
Method setter = descriptor.getWriteMethod();
if (getter == null || setter == null || descriptor.getPropertyType() == null) {
continue;
}
String name = descriptor.getName();
Property<?> property = createProperty(descriptor.getPropertyType(), read(getter, name));
watch(property, setter, name);
properties.put(name, property);
}
}
/**
* Returns the JavaFX property for an Attribute name.
*
* @param name the Attribute name
* @return the property, or {@code null} when that name is not a read-write Attribute
*/
public Property<?> property(String name) {
return properties.get(name);
}
private static List<PropertyDescriptor> descriptors(Class<?> type) {
try {
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
return List.of(info.getPropertyDescriptors());
} catch (IntrospectionException failure) {
throw new IllegalStateException("Could not introspect " + type.getName(), failure);
}
}
private static Property<?> createProperty(Class<?> type, Object value) {
if (type == String.class) {
return new SimpleStringProperty((String) value);
}
if (type == Integer.class || type == int.class) {
return integerProperty(value);
}
if (type == Long.class || type == long.class) {
return longProperty(value);
}
if (type == Float.class || type == float.class) {
return floatProperty(value);
}
if (type == Double.class || type == double.class) {
return doubleProperty(value);
}
if (type == Boolean.class || type == boolean.class) {
return booleanProperty(value);
}
return new SimpleObjectProperty<>(value);
}
private static IntegerProperty integerProperty(Object value) {
SimpleIntegerProperty property = new SimpleIntegerProperty();
if (value != null) {
property.setValue((Integer) value);
}
return property;
}
private static LongProperty longProperty(Object value) {
SimpleLongProperty property = new SimpleLongProperty();
if (value != null) {
property.setValue((Long) value);
}
return property;
}
private static FloatProperty floatProperty(Object value) {
SimpleFloatProperty property = new SimpleFloatProperty();
if (value != null) {
property.setValue((Float) value);
}
return property;
}
private static DoubleProperty doubleProperty(Object value) {
SimpleDoubleProperty property = new SimpleDoubleProperty();
if (value != null) {
property.setValue((Double) value);
}
return property;
}
private static BooleanProperty booleanProperty(Object value) {
SimpleBooleanProperty property = new SimpleBooleanProperty();
if (value != null) {
property.setValue((Boolean) value);
}
return property;
}
private Object read(Method getter, String name) {
try {
return getter.invoke(model);
} catch (IllegalAccessException | InvocationTargetException failure) {
throw new IllegalStateException("Could not read attribute " + name, failure);
}
}
private void watch(Property<?> property, Method setter, String name) {
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
}
private void write(Method setter, String name, Object value) {
try {
setter.invoke(model, value);
} catch (IllegalAccessException | InvocationTargetException failure) {
throw new IllegalStateException("Could not write attribute " + name, failure);
}
}
}
@@ -0,0 +1,160 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.fixture.Person;
import de.neitzel.injection.ApplicationContext;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
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.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
class ShowAndEditOneAttributeTest {
@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 bothWaysTextFieldShowsTheNameAndTypingUpdatesTheModel() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
assertEquals("Ada", nameField.getText());
nameField.setText("Grace");
assertEquals("Grace", person.getName());
});
}
@Test
void towardViewLabelShowsTheNameFollowsTheFieldAndDoesNotWriteTheModel() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-and-view.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
Label nameLabel = (Label) loaded.getRoot().lookup("#nameLabel");
assertEquals("Ada", nameLabel.getText());
nameField.setText("Grace");
assertEquals("Grace", nameLabel.getText());
assertEquals("Grace", person.getName());
nameLabel.setText("Hopper");
assertEquals("Grace", person.getName());
});
}
@Test
void entitySetterAfterLoadLeavesTheTextControlUnchanged() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
person.setName("Grace");
TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
assertEquals("Ada", nameField.getText());
});
}
@Test
void bindingAReadOnlyGetterFailsTheLoad() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ShowAndEditOneAttributeTest.class.getResource("read-only-title.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> ViewLoader.load(fxml, person, context));
assertTrue(failure.getMessage().contains("title"));
});
}
@Test
void bindingAMethodDeclaredOnObjectFailsTheLoad() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ShowAndEditOneAttributeTest.class.getResource("object-class.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> ViewLoader.load(fxml, person, context));
assertTrue(failure.getMessage().contains("class"));
});
}
@Test
void nullIntegerAttributeUsesAnIntegerProperty() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
assertInstanceOf(IntegerProperty.class, loaded.property("age"));
});
}
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;
}
}
@@ -0,0 +1,64 @@
package de.neitzel.fx.view.fixture;
/**
* Ordinary model used by view-loading tests.
*/
public class Person {
private String name;
private Integer age;
/**
* Creates a person with the given name.
*
* @param name the current name
*/
public Person(String name) {
this.name = name;
}
/**
* Returns the name.
*
* @return the name
*/
public String getName() {
return name;
}
/**
* Sets the name.
*
* @param name the name
*/
public void setName(String name) {
this.name = name;
}
/**
* Returns a title that cannot be written.
*
* @return the title
*/
public String getTitle() {
return "Doctor";
}
/**
* Returns the age. The value may be null.
*
* @return the age
*/
public Integer getAge() {
return age;
}
/**
* Sets the age.
*
* @param age the age
*/
public void setAge(Integer age) {
this.age = age;
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
<?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="nameField" property="text" path="name" direction="both"/>
<DirectBinding control="nameLabel" property="text" path="name" direction="view"/>
</BindingList>
</fx:define>
<TextField fx:id="nameField" id="nameField"/>
<Label fx:id="nameLabel" id="nameLabel"/>
</VBox>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.TextField?>
<?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="nameField" property="text" path="name" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="nameField" id="nameField"/>
</VBox>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.TextField?>
<?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="classField" property="text" path="class" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="classField" id="classField"/>
</VBox>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.TextField?>
<?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="titleField" property="text" path="title" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="titleField" id="titleField"/>
</VBox>