diff --git a/fx/src/main/java/de/neitzel/fx/view/BindingList.java b/fx/src/main/java/de/neitzel/fx/view/BindingList.java
index 5d84696..ff8c626 100644
--- a/fx/src/main/java/de/neitzel/fx/view/BindingList.java
+++ b/fx/src/main/java/de/neitzel/fx/view/BindingList.java
@@ -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}.
*
- * 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 elements = new ArrayList<>();
+ private final List 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.
*
- * 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 getElements() {
+ public List 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 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 namespace, ViewModel viewModel) {
- for (DirectBinding binding : elements) {
- DirectBindingApplier.apply(namespace, viewModel, binding);
+ void apply(Map 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);
+ }
}
}
}
diff --git a/fx/src/main/java/de/neitzel/fx/view/ComputedBinding.java b/fx/src/main/java/de/neitzel/fx/view/ComputedBinding.java
new file mode 100644
index 0000000..326758c
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/ComputedBinding.java
@@ -0,0 +1,70 @@
+package de.neitzel.fx.view;
+
+/**
+ * A Computed binding declared in FXML.
+ *
+ * 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;
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/ComputedBindingApplier.java b/fx/src/main/java/de/neitzel/fx/view/ComputedBindingApplier.java
new file mode 100644
index 0000000..b5b3d61
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/ComputedBindingApplier.java
@@ -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.
+ *
+ * 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 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 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 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 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) 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 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) 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;
+ }
+}
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..111676f 100644
--- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java
@@ -18,8 +18,9 @@ public final class ViewLoader {
/**
* Loads a View for one Model instance.
*
- * 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
+ * Builds a ViewModel for {@code model}, loads {@code fxml}, 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
@@ -43,7 +44,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) {
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..0361eb7 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,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;
@@ -70,6 +71,18 @@ public class ViewModel {
return properties.get(name);
}
+ /**
+ * Calls {@code listener} when any Attribute property changes.
+ *
+ * @param listener the listener
+ */
+ @SuppressWarnings("unchecked")
+ void addAttributeListener(ChangeListener listener) {
+ for (Property> property : properties.values()) {
+ ((Property) property).addListener(listener);
+ }
+ }
+
private static List descriptors(Class> type) {
try {
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
diff --git a/fx/src/test/java/de/neitzel/fx/view/ComputeAValueTest.java b/fx/src/test/java/de/neitzel/fx/view/ComputeAValueTest.java
new file mode 100644
index 0000000..6b0afdb
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/ComputeAValueTest.java
@@ -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 name = (Property) 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 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/computed/WorkingAgeController.java b/fx/src/test/java/de/neitzel/fx/view/computed/WorkingAgeController.java
new file mode 100644
index 0000000..090ad4b
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/computed/WorkingAgeController.java
@@ -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";
+ }
+}
diff --git a/fx/src/test/resources/de/neitzel/fx/view/working-age.fxml b/fx/src/test/resources/de/neitzel/fx/view/working-age.fxml
new file mode 100644
index 0000000..00a8bc9
--- /dev/null
+++ b/fx/src/test/resources/de/neitzel/fx/view/working-age.fxml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+