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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user