Merge computed bindings into nested ViewModels.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 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>
|
||||
Reference in New Issue
Block a user