Merge computed bindings into nested ViewModels.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-30 17:41:10 +01:00
co-authored by Cursor
8 changed files with 442 additions and 16 deletions
@@ -1,6 +1,7 @@
package de.neitzel.fx.view;
import javafx.beans.DefaultProperty;
import javafx.scene.Parent;
import java.util.ArrayList;
import java.util.List;
@@ -9,13 +10,13 @@ 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()}.
* The list is not a scene-graph child. FXML adds each {@link DirectBinding} or
* {@link ComputedBinding} child to {@link #getElements()}.
*/
@DefaultProperty("elements")
public class BindingList {
private final List<DirectBinding> elements = new ArrayList<>();
private final List<Object> elements = new ArrayList<>();
/**
* Creates a list and registers it with the load in progress.
@@ -25,25 +26,34 @@ public class BindingList {
}
/**
* Returns the Direct bindings in this list.
* Returns the binding elements in this list.
* <p>
* FXML appends child elements to the returned list.
* FXML appends child elements to the returned list. A child is a {@link DirectBinding} or a
* {@link ComputedBinding}.
*
* @return the mutable list of Direct bindings
* @return the mutable list of binding elements
*/
public List<DirectBinding> getElements() {
public List<Object> getElements() {
return elements;
}
/**
* Applies every Direct binding in this list.
* Applies every binding element in this list.
*
* @param namespace objects addressed by {@code fx:id}
* @param viewModel the ViewModel for this View
* @param controller the Controller for this View, or {@code null} when the document names none
* @param root the scene-graph root of this View
*/
void apply(Map<String, Object> namespace, ViewModel viewModel) {
for (DirectBinding binding : elements) {
DirectBindingApplier.apply(namespace, viewModel, binding);
void apply(Map<String, Object> namespace, ViewModel viewModel, Object controller, Parent root) {
for (Object element : elements) {
if (element instanceof DirectBinding directBinding) {
DirectBindingApplier.apply(namespace, viewModel, directBinding);
} else if (element instanceof ComputedBinding computedBinding) {
ComputedBindingApplier.apply(namespace, viewModel, controller, root, computedBinding);
} else {
throw new IllegalArgumentException("Not a binding element: " + element);
}
}
}
}
@@ -0,0 +1,70 @@
package de.neitzel.fx.view;
/**
* A Computed binding declared in FXML.
* <p>
* The element names the control by {@code fx:id}, the control property, and a no-argument
* method on the Controller. The method value uses a {@code #method} prefix, such as
* {@code #workingAge}. The method may read the Controller's fields. Its result is written to
* the control property. The method does not write the Model.
*/
public class ComputedBinding {
private String control;
private String property;
private String method;
/**
* 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 Controller method. A {@code #method} value names a no-argument method.
*
* @param method the method value, such as {@code #workingAge}
*/
public void setMethod(String method) {
this.method = method;
}
/**
* 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 Controller method value.
*
* @return the method value, such as {@code #workingAge}
*/
public String getMethod() {
return method;
}
}
@@ -0,0 +1,147 @@
package de.neitzel.fx.view;
import javafx.beans.property.Property;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.Control;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;
/**
* Writes a Controller method result into one control property and keeps it current.
*/
final class ComputedBindingApplier {
private ComputedBindingApplier() {
}
/**
* Applies one Computed binding.
* <p>
* Calls the Controller method and writes the result to the control property. The method
* runs again when a JavaFX property changes on a control in this View or on this View's
* ViewModel, other than the target property. Writing the target does not call the method
* again, so the write does not loop. The Model is not written.
*
* @param namespace objects addressed by {@code fx:id}
* @param viewModel the ViewModel for this View
* @param controller the Controller for this View
* @param root the scene-graph root of this View
* @param binding the declaration
*/
static void apply(
Map<String, Object> namespace,
ViewModel viewModel,
Object controller,
Parent root,
ComputedBinding binding) {
Object control = namespace.get(binding.getControl());
if (control == null) {
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
}
Property<Object> target = controlProperty(control, binding.getProperty());
Runnable refresh = refresh(controller, binding.getMethod(), target);
refresh.run();
watchControls(root, target, refresh);
viewModel.addAttributeListener((observable, oldValue, newValue) -> refresh.run());
}
private static Runnable refresh(Object controller, String methodValue, Property<Object> target) {
boolean[] running = {false};
return () -> {
if (running[0]) {
return;
}
running[0] = true;
try {
target.setValue(invoke(controller, methodValue));
} finally {
running[0] = false;
}
};
}
private static void watchControls(Parent root, Property<?> target, Runnable refresh) {
watchNode(root, target, refresh);
}
private static void watchNode(Node node, Property<?> target, Runnable refresh) {
if (node instanceof Control control) {
watchProperties(control, target, refresh);
}
if (node instanceof Parent parent) {
for (Node child : parent.getChildrenUnmodifiable()) {
watchNode(child, target, refresh);
}
}
}
@SuppressWarnings("unchecked")
private static void watchProperties(Object bean, Property<?> target, Runnable refresh) {
ChangeListener<Object> listener = (observable, oldValue, newValue) -> refresh.run();
for (Method method : bean.getClass().getMethods()) {
if (method.getParameterCount() != 0 || !method.getName().endsWith("Property")) {
continue;
}
if (!ObservableValue.class.isAssignableFrom(method.getReturnType())) {
continue;
}
Object value = readProperty(bean, method);
if (value instanceof ObservableValue<?> observable && observable != target) {
((ObservableValue<Object>) observable).addListener(listener);
}
}
}
private static Object readProperty(Object bean, Method method) {
try {
return method.invoke(bean);
} catch (IllegalAccessException | InvocationTargetException failure) {
throw new IllegalStateException("Could not read property " + method.getName(), failure);
}
}
@SuppressWarnings("unchecked")
private static Property<Object> 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<Object>) 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);
}
}
private static Object invoke(Object controller, String methodValue) {
if (controller == null) {
throw new IllegalArgumentException("No controller for " + methodValue);
}
String name = methodName(methodValue);
try {
Method method = controller.getClass().getMethod(name);
return method.invoke(controller);
} catch (NoSuchMethodException failure) {
throw new IllegalArgumentException("No computed method \"" + methodValue + "\"", failure);
} catch (IllegalAccessException | InvocationTargetException failure) {
throw new IllegalStateException("Could not call " + methodValue, failure);
}
}
private static String methodName(String methodValue) {
if (methodValue != null && methodValue.startsWith("#")) {
return methodValue.substring(1);
}
return methodValue;
}
}
@@ -19,8 +19,9 @@ public final class ViewLoader {
* Loads a View for one Model instance.
* <p>
* Reuses the ViewModel already built for {@code model}, or builds one, then loads {@code fxml}
* and applies Direct bindings declared in {@code fx:define}. Direction {@code both} keeps the
* control and the Attribute
* and applies binding elements declared in {@code fx:define}. A Computed binding writes a
* Controller method result to a control and does not write the Model. 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
@@ -47,7 +48,7 @@ public final class ViewLoader {
throw new IllegalArgumentException("View root must be a parent");
}
for (BindingList bindingList : session.bindingLists()) {
bindingList.apply(loader.getNamespace(), viewModel);
bindingList.apply(loader.getNamespace(), viewModel, loader.getController(), root);
}
return new LoadedView(root, viewModel);
} catch (IOException failure) {
@@ -13,6 +13,7 @@ import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleLongProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.value.ChangeListener;
import java.beans.BeanInfo;
import java.beans.IntrospectionException;
@@ -112,6 +113,18 @@ public class ViewModel {
return complexAttributes.contains(name);
}
/**
* Calls {@code listener} when any Attribute property changes.
*
* @param listener the listener
*/
@SuppressWarnings("unchecked")
void addAttributeListener(ChangeListener<Object> listener) {
for (Property<?> property : properties.values()) {
((Property<Object>) property).addListener(listener);
}
}
private static List<PropertyDescriptor> descriptors(Class<?> type) {
try {
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
@@ -0,0 +1,135 @@
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.Property;
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.fail;
class ComputeAValueTest {
@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 computedBindingShowsTheMethodResultAndLeavesTheModelUnchanged() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(36);
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
assertEquals("initial-age", workingAgeLabel.getText());
assertEquals("Ada", person.getName());
assertEquals(36, person.getAge());
});
}
@Test
void typingInAnotherTextFieldUpdatesTheComputedLabel() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
nameField.setText("Grace");
assertEquals("typed-age", workingAgeLabel.getText());
assertEquals("Grace", person.getName());
});
}
@Test
void settingAViewModelPropertyUpdatesTheComputedLabel() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
@SuppressWarnings("unchecked")
Property<String> name = (Property<String>) loaded.property("name");
name.setValue("Edsger");
assertEquals("model-age", workingAgeLabel.getText());
assertEquals("Edsger", person.getName());
});
}
@Test
void changingTheComputedLabelDoesNotRunTheMethodAgain() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
assertEquals("initial-age", workingAgeLabel.getText());
workingAgeLabel.setText("sentinel");
assertEquals("sentinel", workingAgeLabel.getText());
});
}
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,30 @@
package de.neitzel.fx.view.computed;
import jakarta.inject.Singleton;
import javafx.fxml.FXML;
import javafx.scene.control.TextField;
/**
* Controller that derives a label from the name field and does not write the Model.
*/
@Singleton
public class WorkingAgeController {
@FXML
private TextField nameField;
/**
* Returns a literal chosen from the current name field.
*
* @return the value written to the label
*/
public String workingAge() {
if ("Ada".equals(nameField.getText())) {
return "initial-age";
}
if ("Grace".equals(nameField.getText())) {
return "typed-age";
}
return "model-age";
}
}
@@ -0,0 +1,20 @@
<?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.ComputedBinding?>
<?import de.neitzel.fx.view.DirectBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
fx:controller="de.neitzel.fx.view.computed.WorkingAgeController">
<fx:define>
<BindingList>
<DirectBinding control="nameField" property="text" path="name" direction="both"/>
<ComputedBinding control="workingAgeLabel" property="text" method="#workingAge"/>
</BindingList>
</fx:define>
<TextField fx:id="nameField" id="nameField"/>
<Label fx:id="workingAgeLabel" id="workingAgeLabel"/>
</VBox>