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 1fef237..0836332 100644 --- a/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java +++ b/fx/src/main/java/de/neitzel/fx/view/ViewLoader.java @@ -19,8 +19,9 @@ public final class ViewLoader { * Loads a View for one Model instance. *

* 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) { 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 b2b1d93..7a4e29d 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; @@ -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 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 @@ + + + + + + + + + + + + + + + + + +