Let a Direct binding convert a value and keep one failure Message.
A bad control value must leave the previous Attribute in place, and the ViewModel should keep a single error for that Path until the value is fixed. An info notice stays on the ViewModel and is not written to the log. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.beans.DefaultProperty;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -40,10 +41,11 @@ public class BindingList {
|
||||
*
|
||||
* @param namespace objects addressed by {@code fx:id}
|
||||
* @param viewModel the ViewModel for this View
|
||||
* @param context the container that creates a named Converter
|
||||
*/
|
||||
void apply(Map<String, Object> namespace, ViewModel viewModel) {
|
||||
void apply(Map<String, Object> namespace, ViewModel viewModel, ApplicationContext context) {
|
||||
for (DirectBinding binding : elements) {
|
||||
DirectBindingApplier.apply(namespace, viewModel, binding);
|
||||
DirectBindingApplier.apply(namespace, viewModel, binding, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* The developer-written translation between a Direct binding's Attribute value and its control value.
|
||||
* <p>
|
||||
* The container constructs the class and injects its services. {@link #toControlValue(Object)} turns
|
||||
* the Attribute value into the control value. {@link #toAttributeValue(Object)} turns the control
|
||||
* value into the Attribute value and runs only when the Direct binding runs toward the Model.
|
||||
* A toward-view binding does not call the reverse method, so a converter used only toward the View
|
||||
* can leave it unimplemented. When a method cannot produce a value, it throws a runtime exception.
|
||||
* The binding then keeps the previous value.
|
||||
*/
|
||||
public interface Converter {
|
||||
|
||||
/**
|
||||
* Turns the Attribute value into the control value.
|
||||
*
|
||||
* @param attributeValue the current Attribute value, which may be null
|
||||
* @return the value written to the control
|
||||
*/
|
||||
Object toControlValue(Object attributeValue);
|
||||
|
||||
/**
|
||||
* Turns the control value into the Attribute value.
|
||||
* <p>
|
||||
* Called only when the Direct binding runs toward the Model ({@code model} or {@code both}).
|
||||
* A toward-view binding does not require this method.
|
||||
*
|
||||
* @param controlValue the current control value
|
||||
* @return the value written to the Attribute
|
||||
*/
|
||||
default Object toAttributeValue(Object controlValue) {
|
||||
throw new UnsupportedOperationException("no reverse conversion for " + controlValue);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ 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}.
|
||||
* direction, and an optional Converter class. A Path of one name is that Attribute of this
|
||||
* View's ViewModel. Direction values are {@code view}, {@code model}, and {@code both}.
|
||||
* The Converter value is the class name. The container creates that class.
|
||||
*/
|
||||
public class DirectBinding {
|
||||
|
||||
@@ -13,6 +14,7 @@ public class DirectBinding {
|
||||
private String property;
|
||||
private String path;
|
||||
private String direction;
|
||||
private String converter;
|
||||
|
||||
/**
|
||||
* Sets the {@code fx:id} of the control.
|
||||
@@ -50,6 +52,15 @@ public class DirectBinding {
|
||||
this.direction = direction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Converter class name. The container creates that class.
|
||||
*
|
||||
* @param converter the converter class name, or {@code null} when values pass through
|
||||
*/
|
||||
public void setConverter(String converter) {
|
||||
this.converter = converter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@code fx:id} of the control.
|
||||
*
|
||||
@@ -85,4 +96,13 @@ public class DirectBinding {
|
||||
public String getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Converter class name.
|
||||
*
|
||||
* @return the class name, or {@code null} when values pass through
|
||||
*/
|
||||
public String getConverter() {
|
||||
return converter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.beans.property.Property;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
@@ -20,8 +21,10 @@ final class DirectBindingApplier {
|
||||
* @param namespace objects addressed by {@code fx:id}
|
||||
* @param viewModel the ViewModel for this View
|
||||
* @param binding the declaration
|
||||
* @param context the container that creates a named Converter
|
||||
*/
|
||||
static void apply(Map<String, Object> namespace, ViewModel viewModel, DirectBinding binding) {
|
||||
static void apply(Map<String, Object> namespace, ViewModel viewModel, DirectBinding binding,
|
||||
ApplicationContext context) {
|
||||
Object control = namespace.get(binding.getControl());
|
||||
if (control == null) {
|
||||
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
|
||||
@@ -32,14 +35,125 @@ final class DirectBindingApplier {
|
||||
throw new IllegalArgumentException(
|
||||
"No read-write attribute for path \"" + binding.getPath() + "\"");
|
||||
}
|
||||
String converterName = binding.getConverter();
|
||||
if (converterName == null || converterName.isEmpty()) {
|
||||
alignPlain(controlProperty, attribute, binding.getDirection());
|
||||
return;
|
||||
}
|
||||
Converter converter = converter(context, converterName);
|
||||
switch (binding.getDirection()) {
|
||||
case "both" -> alignConverted(
|
||||
controlProperty, attribute, converter, viewModel, binding.getPath(), true, true);
|
||||
case "view" -> alignConverted(
|
||||
controlProperty, attribute, converter, viewModel, binding.getPath(), true, false);
|
||||
case "model" -> alignConverted(
|
||||
controlProperty, attribute, converter, viewModel, binding.getPath(), false, true);
|
||||
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
|
||||
}
|
||||
}
|
||||
|
||||
private static void alignPlain(Property<?> controlProperty, Property<?> attribute, String direction) {
|
||||
switch (direction) {
|
||||
case "both" -> alignBoth(controlProperty, attribute);
|
||||
case "view" -> alignTowardView(controlProperty, attribute);
|
||||
case "model" -> alignTowardModel(controlProperty, attribute);
|
||||
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
|
||||
default -> throw new IllegalArgumentException("Not a direction: " + direction);
|
||||
}
|
||||
}
|
||||
|
||||
private static Converter converter(ApplicationContext context, String className) {
|
||||
try {
|
||||
Class<?> type = Class.forName(className);
|
||||
Object instance = context.getComponent(type.asSubclass(Object.class));
|
||||
if (!(instance instanceof Converter created)) {
|
||||
throw new IllegalArgumentException("Not a converter: " + className);
|
||||
}
|
||||
return created;
|
||||
} catch (ClassNotFoundException failure) {
|
||||
throw new IllegalArgumentException("No converter " + className, failure);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignConverted(Property<?> controlProperty, Property<?> attribute, Converter converter,
|
||||
ViewModel viewModel, String path, boolean towardView, boolean towardModel) {
|
||||
Property<Object> control = (Property<Object>) controlProperty;
|
||||
Property<Object> source = (Property<Object>) attribute;
|
||||
Link link = new Link();
|
||||
pushControl(control, source, converter, viewModel, path, link);
|
||||
if (towardView) {
|
||||
source.addListener((observable, oldValue, newValue) -> {
|
||||
if (!link.applying) {
|
||||
pushControl(control, source, converter, viewModel, path, link);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (towardModel) {
|
||||
control.addListener((observable, oldValue, newValue) -> {
|
||||
if (!link.applying) {
|
||||
pushAttribute(control, source, converter, viewModel, path, link);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void pushControl(Property<Object> control, Property<Object> attribute, Converter converter,
|
||||
ViewModel viewModel, String path, Link link) {
|
||||
try {
|
||||
Object shown = converter.toControlValue(attribute.getValue());
|
||||
link.applying = true;
|
||||
try {
|
||||
control.setValue(shown);
|
||||
link.committedControl = shown;
|
||||
} finally {
|
||||
link.applying = false;
|
||||
}
|
||||
viewModel.clearBindingFailure(path);
|
||||
} catch (RuntimeException failure) {
|
||||
viewModel.reportBindingFailure(path, failureText(failure));
|
||||
}
|
||||
}
|
||||
|
||||
private static void pushAttribute(Property<Object> control, Property<Object> attribute, Converter converter,
|
||||
ViewModel viewModel, String path, Link link) {
|
||||
try {
|
||||
Object converted = converter.toAttributeValue(control.getValue());
|
||||
link.applying = true;
|
||||
try {
|
||||
attribute.setValue(converted);
|
||||
link.committedControl = control.getValue();
|
||||
} finally {
|
||||
link.applying = false;
|
||||
}
|
||||
viewModel.clearBindingFailure(path);
|
||||
} catch (RuntimeException failure) {
|
||||
link.applying = true;
|
||||
try {
|
||||
control.setValue(link.committedControl);
|
||||
} finally {
|
||||
link.applying = false;
|
||||
}
|
||||
viewModel.reportBindingFailure(path, failureText(failure));
|
||||
}
|
||||
}
|
||||
|
||||
private static String failureText(RuntimeException failure) {
|
||||
String text = failure.getMessage();
|
||||
if (text == null || text.isEmpty()) {
|
||||
return "Conversion failed";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards one converted binding against hearing its own writes, and remembers the last
|
||||
* control value that was successfully shown.
|
||||
*/
|
||||
private static final class Link {
|
||||
private boolean applying;
|
||||
private Object committedControl;
|
||||
}
|
||||
|
||||
private static Property<?> controlProperty(Object control, String propertyName) {
|
||||
String methodName = propertyName + "Property";
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* A notice on a ViewModel with a severity, a text, and a source.
|
||||
*/
|
||||
public final class Message {
|
||||
|
||||
/**
|
||||
* How serious a notice is.
|
||||
*/
|
||||
public enum Severity {
|
||||
/** Informational notice. Not written to the log. */
|
||||
INFO,
|
||||
/** Warning. Not written to the log. */
|
||||
WARN,
|
||||
/** Error. Written to the log. */
|
||||
ERROR,
|
||||
/** Fatal error. Written to the log. */
|
||||
FATAL
|
||||
}
|
||||
|
||||
private final Severity severity;
|
||||
private String text;
|
||||
private final String source;
|
||||
|
||||
/**
|
||||
* Creates a notice.
|
||||
*
|
||||
* @param severity how serious the notice is
|
||||
* @param text the notice text
|
||||
* @param source where the notice came from, such as a binding Path
|
||||
*/
|
||||
public Message(Severity severity, String text, String source) {
|
||||
this.severity = severity;
|
||||
this.text = text;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how serious the notice is.
|
||||
*
|
||||
* @return the severity
|
||||
*/
|
||||
public Severity getSeverity() {
|
||||
return severity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the notice text.
|
||||
*
|
||||
* @return the text
|
||||
*/
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns where the notice came from.
|
||||
*
|
||||
* @return the source
|
||||
*/
|
||||
public String getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the text of a binding failure that is already in the list.
|
||||
*
|
||||
* @param text the new text
|
||||
*/
|
||||
void setText(String text) {
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,9 @@ public final class ViewLoader {
|
||||
* 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.
|
||||
* read-write Attribute fails the load. The context creates a Controller, and a Converter when a
|
||||
* Direct binding names one. A converter that cannot produce a value keeps the previous value
|
||||
* and records one error Message. It does not fail the load.
|
||||
*
|
||||
* @param fxml the FXML document
|
||||
* @param model the Model instance
|
||||
@@ -43,7 +45,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, context);
|
||||
}
|
||||
return new LoadedView(root, viewModel);
|
||||
} catch (IOException failure) {
|
||||
|
||||
@@ -13,6 +13,8 @@ import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.beans.property.SimpleLongProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.beans.BeanInfo;
|
||||
import java.beans.IntrospectionException;
|
||||
@@ -20,6 +22,8 @@ import java.beans.Introspector;
|
||||
import java.beans.PropertyDescriptor;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -34,11 +38,20 @@ import java.util.Map;
|
||||
* 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.
|
||||
* <p>
|
||||
* Beside those attributes, the ViewModel keeps a list of {@link Message}s and the latest one.
|
||||
* The developer can add a message of any severity. Error and fatal messages are written to the log.
|
||||
* Info and warn are not.
|
||||
*/
|
||||
public class ViewModel {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ViewModel.class);
|
||||
|
||||
private final Object model;
|
||||
private final Map<String, Property<?>> properties = new HashMap<>();
|
||||
private final List<Message> messages = new ArrayList<>();
|
||||
private final Map<String, Message> bindingFailures = new HashMap<>();
|
||||
private Message latestMessage;
|
||||
|
||||
/**
|
||||
* Builds a ViewModel for one Model instance.
|
||||
@@ -160,4 +173,81 @@ public class ViewModel {
|
||||
throw new IllegalStateException("Could not write attribute " + name, failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the notices on this ViewModel, beside the Model attributes.
|
||||
*
|
||||
* @return the messages, oldest first
|
||||
*/
|
||||
public List<Message> getMessages() {
|
||||
return Collections.unmodifiableList(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the notice most recently added.
|
||||
* <p>
|
||||
* An in-place update of a binding failure does not change which notice is latest.
|
||||
*
|
||||
* @return the latest notice, or {@code null} when there is none
|
||||
*/
|
||||
public Message getLatestMessage() {
|
||||
return latestMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a notice of any severity.
|
||||
*
|
||||
* @param message the notice
|
||||
*/
|
||||
public void addMessage(Message message) {
|
||||
messages.add(message);
|
||||
latestMessage = message;
|
||||
logSevere(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records one error for a binding Path. A further failure of that Path replaces the text.
|
||||
*
|
||||
* @param path the binding Path
|
||||
* @param text the error text
|
||||
*/
|
||||
void reportBindingFailure(String path, String text) {
|
||||
Message existing = bindingFailures.get(path);
|
||||
if (existing != null) {
|
||||
existing.setText(text);
|
||||
logSevere(existing);
|
||||
return;
|
||||
}
|
||||
Message message = new Message(Message.Severity.ERROR, text, path);
|
||||
bindingFailures.put(path, message);
|
||||
addMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the error recorded for a binding Path after a later conversion succeeds.
|
||||
*
|
||||
* @param path the binding Path
|
||||
*/
|
||||
void clearBindingFailure(String path) {
|
||||
Message existing = bindingFailures.remove(path);
|
||||
if (existing == null) {
|
||||
return;
|
||||
}
|
||||
messages.remove(existing);
|
||||
if (latestMessage == existing) {
|
||||
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an error or fatal notice to the log. Info and warn are left out.
|
||||
*
|
||||
* @param message the notice
|
||||
*/
|
||||
private static void logSevere(Message message) {
|
||||
Message.Severity severity = message.getSeverity();
|
||||
if (severity == Message.Severity.ERROR || severity == Message.Severity.FATAL) {
|
||||
LOGGER.error(message.getText());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user