Merge value conversion and failure Messages into the View loader.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
package de.neitzel.fx.view;
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
|
import de.neitzel.injection.ApplicationContext;
|
||||||
import javafx.beans.DefaultProperty;
|
import javafx.beans.DefaultProperty;
|
||||||
import javafx.scene.Parent;
|
import javafx.scene.Parent;
|
||||||
|
|
||||||
@@ -44,11 +45,13 @@ public class BindingList {
|
|||||||
* @param viewModel the ViewModel for this View
|
* @param viewModel the ViewModel for this View
|
||||||
* @param controller the Controller for this View, or {@code null} when the document names none
|
* @param controller the Controller for this View, or {@code null} when the document names none
|
||||||
* @param root the scene-graph root of this View
|
* @param root the scene-graph root of this View
|
||||||
|
* @param context the container that creates a named Converter
|
||||||
*/
|
*/
|
||||||
void apply(Map<String, Object> namespace, ViewModel viewModel, Object controller, Parent root) {
|
void apply(Map<String, Object> namespace, ViewModel viewModel, Object controller, Parent root,
|
||||||
|
ApplicationContext context) {
|
||||||
for (Object element : elements) {
|
for (Object element : elements) {
|
||||||
if (element instanceof DirectBinding directBinding) {
|
if (element instanceof DirectBinding directBinding) {
|
||||||
DirectBindingApplier.apply(namespace, viewModel, directBinding);
|
DirectBindingApplier.apply(namespace, viewModel, directBinding, context);
|
||||||
} else if (element instanceof ComputedBinding computedBinding) {
|
} else if (element instanceof ComputedBinding computedBinding) {
|
||||||
ComputedBindingApplier.apply(namespace, viewModel, controller, root, computedBinding);
|
ComputedBindingApplier.apply(namespace, viewModel, controller, root, computedBinding);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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,10 +4,11 @@ package de.neitzel.fx.view;
|
|||||||
* A Direct binding declared in FXML.
|
* A Direct binding declared in FXML.
|
||||||
* <p>
|
* <p>
|
||||||
* The element names the control by {@code fx:id}, the control property, the Path, and the
|
* 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. Further names step
|
* direction, and an optional Converter class. A Path of one name is that Attribute of this
|
||||||
* into the ViewModel of a Complex attribute, and the direction applies to the last name.
|
* View's ViewModel. Further names step into the ViewModel of a Complex attribute, and the
|
||||||
* When that last name is complex, the control receives the Model instance behind the ViewModel.
|
* direction applies to the last name. When that last name is complex, the control receives the
|
||||||
* Direction values are {@code view}, {@code model}, and {@code both}.
|
* Model instance behind the 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 {
|
public class DirectBinding {
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ public class DirectBinding {
|
|||||||
private String property;
|
private String property;
|
||||||
private String path;
|
private String path;
|
||||||
private String direction;
|
private String direction;
|
||||||
|
private String converter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the {@code fx:id} of the control.
|
* Sets the {@code fx:id} of the control.
|
||||||
@@ -52,6 +54,15 @@ public class DirectBinding {
|
|||||||
this.direction = direction;
|
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.
|
* Returns the {@code fx:id} of the control.
|
||||||
*
|
*
|
||||||
@@ -87,4 +98,13 @@ public class DirectBinding {
|
|||||||
public String getDirection() {
|
public String getDirection() {
|
||||||
return direction;
|
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;
|
package de.neitzel.fx.view;
|
||||||
|
|
||||||
|
import de.neitzel.injection.ApplicationContext;
|
||||||
import javafx.beans.property.Property;
|
import javafx.beans.property.Property;
|
||||||
|
|
||||||
import java.lang.reflect.InvocationTargetException;
|
import java.lang.reflect.InvocationTargetException;
|
||||||
@@ -20,8 +21,10 @@ final class DirectBindingApplier {
|
|||||||
* @param namespace objects addressed by {@code fx:id}
|
* @param namespace objects addressed by {@code fx:id}
|
||||||
* @param viewModel the ViewModel for this View
|
* @param viewModel the ViewModel for this View
|
||||||
* @param binding the declaration
|
* @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());
|
Object control = namespace.get(binding.getControl());
|
||||||
if (control == null) {
|
if (control == null) {
|
||||||
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
|
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
|
||||||
@@ -41,11 +44,29 @@ final class DirectBindingApplier {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
String converterName = binding.getConverter();
|
||||||
|
if (converterName == null || converterName.isEmpty()) {
|
||||||
|
alignPlain(controlProperty, attribute, binding.getDirection());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Converter converter = converter(context, converterName);
|
||||||
switch (binding.getDirection()) {
|
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 "both" -> alignBoth(controlProperty, attribute);
|
||||||
case "view" -> alignTowardView(controlProperty, attribute);
|
case "view" -> alignTowardView(controlProperty, attribute);
|
||||||
case "model" -> alignTowardModel(controlProperty, attribute);
|
case "model" -> alignTowardModel(controlProperty, attribute);
|
||||||
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
|
default -> throw new IllegalArgumentException("Not a direction: " + direction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +125,99 @@ final class DirectBindingApplier {
|
|||||||
return new IllegalArgumentException("No read-write attribute for path \"" + path + "\"");
|
return new IllegalArgumentException("No read-write attribute for path \"" + path + "\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private static Property<?> controlProperty(Object control, String propertyName) {
|
||||||
String methodName = propertyName + "Property";
|
String methodName = propertyName + "Property";
|
||||||
try {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,9 @@ public final class ViewLoader {
|
|||||||
* Attribute of this View's ViewModel. Further names step into a nested ViewModel, and the
|
* Attribute of this View's ViewModel. Further names step into a nested ViewModel, and the
|
||||||
* direction applies to the last name. When a Complex attribute along the Path is null, the
|
* direction applies to the last name. When a Complex attribute along the Path is null, the
|
||||||
* control and the Model keep their current values. A path that is not a read-write Attribute
|
* control and the Model keep their current values. A path that is not a read-write Attribute
|
||||||
* fails the load. The context is used when the document names a Controller.
|
* 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 fxml the FXML document
|
||||||
* @param model the Model instance
|
* @param model the Model instance
|
||||||
@@ -48,7 +50,7 @@ public final class ViewLoader {
|
|||||||
throw new IllegalArgumentException("View root must be a parent");
|
throw new IllegalArgumentException("View root must be a parent");
|
||||||
}
|
}
|
||||||
for (BindingList bindingList : session.bindingLists()) {
|
for (BindingList bindingList : session.bindingLists()) {
|
||||||
bindingList.apply(loader.getNamespace(), viewModel, loader.getController(), root);
|
bindingList.apply(loader.getNamespace(), viewModel, loader.getController(), root, context);
|
||||||
}
|
}
|
||||||
return new LoadedView(root, viewModel);
|
return new LoadedView(root, viewModel);
|
||||||
} catch (IOException failure) {
|
} catch (IOException failure) {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import javafx.beans.property.SimpleLongProperty;
|
|||||||
import javafx.beans.property.SimpleObjectProperty;
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
import javafx.beans.property.SimpleStringProperty;
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
import javafx.beans.value.ChangeListener;
|
import javafx.beans.value.ChangeListener;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.beans.BeanInfo;
|
import java.beans.BeanInfo;
|
||||||
import java.beans.IntrospectionException;
|
import java.beans.IntrospectionException;
|
||||||
@@ -21,8 +23,10 @@ import java.beans.Introspector;
|
|||||||
import java.beans.PropertyDescriptor;
|
import java.beans.PropertyDescriptor;
|
||||||
import java.lang.reflect.InvocationTargetException;
|
import java.lang.reflect.InvocationTargetException;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Calendar;
|
import java.util.Calendar;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -46,12 +50,21 @@ import java.util.Set;
|
|||||||
* boolean leaves the primitive property at its default. After that, a change of the property
|
* boolean leaves the primitive property at its default. After that, a change of the property
|
||||||
* writes the Attribute. A Complex attribute writes the Model instance behind the ViewModel.
|
* writes the Attribute. A Complex attribute writes the Model instance behind the ViewModel.
|
||||||
* A call to the entity's setter does not update the property.
|
* 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 {
|
public class ViewModel {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LoggerFactory.getLogger(ViewModel.class);
|
||||||
|
|
||||||
private final Object model;
|
private final Object model;
|
||||||
private final Map<String, Property<?>> properties = new HashMap<>();
|
private final Map<String, Property<?>> properties = new HashMap<>();
|
||||||
private final Set<String> complexAttributes = new HashSet<>();
|
private final Set<String> complexAttributes = new HashSet<>();
|
||||||
|
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.
|
* Builds a ViewModel for one Model instance.
|
||||||
@@ -254,4 +267,81 @@ public class ViewModel {
|
|||||||
throw new IllegalStateException("Could not write attribute " + name, failure);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
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.IntegerProperty;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
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.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.junit.jupiter.api.Assertions.fail;
|
||||||
|
|
||||||
|
class ConvertAndRecordFailureTest {
|
||||||
|
|
||||||
|
@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 injectedConverterPrefixesTheAgeText() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAge(42);
|
||||||
|
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||||
|
assertEquals("years:42", ageField.getText());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void typingAPrefixedAgeUpdatesTheModel() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAge(42);
|
||||||
|
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||||
|
ageField.setText("years:11");
|
||||||
|
assertEquals(11, person.getAge());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void towardViewLoadsWhenTheReverseMethodIsAbsent() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAge(42);
|
||||||
|
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-view-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
Label ageLabel = (Label) loaded.getRoot().lookup("#ageLabel");
|
||||||
|
assertEquals("shown 42", ageLabel.getText());
|
||||||
|
ageLabel.setText("shown 99");
|
||||||
|
assertEquals(42, person.getAge());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void towardViewConverterFollowsALaterAge() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAge(42);
|
||||||
|
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-and-view-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||||
|
Label ageLabel = (Label) loaded.getRoot().lookup("#ageLabel");
|
||||||
|
assertEquals("shown 42", ageLabel.getText());
|
||||||
|
ageField.setText("years:11");
|
||||||
|
assertEquals(11, person.getAge());
|
||||||
|
assertEquals("shown 11", ageLabel.getText());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void towardModelConvertsTypedTextWithoutFollowingTheAttribute() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAge(42);
|
||||||
|
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-model-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||||
|
assertEquals("years:42", ageField.getText());
|
||||||
|
((IntegerProperty) loaded.property("age")).set(7);
|
||||||
|
assertEquals("years:42", ageField.getText());
|
||||||
|
assertEquals(Integer.valueOf(7), person.getAge());
|
||||||
|
ageField.setText("years:8");
|
||||||
|
assertEquals(Integer.valueOf(8), person.getAge());
|
||||||
|
assertEquals("years:8", ageField.getText());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void badAgeTextKeepsThePreviousIntAndOneErrorMessage() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAge(42);
|
||||||
|
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||||
|
ageField.setText("nope");
|
||||||
|
assertEquals(Integer.valueOf(42), person.getAge());
|
||||||
|
assertEquals("years:42", ageField.getText());
|
||||||
|
ViewModel viewModel = loaded.getViewModel();
|
||||||
|
assertEquals(1, viewModel.getMessages().size());
|
||||||
|
Message message = viewModel.getMessages().get(0);
|
||||||
|
assertEquals(Message.Severity.ERROR, message.getSeverity());
|
||||||
|
assertEquals("age", message.getSource());
|
||||||
|
assertEquals("not a whole number: nope", message.getText());
|
||||||
|
assertSame(message, viewModel.getLatestMessage());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anotherBadAgeUpdatesTheSameMessage() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAge(42);
|
||||||
|
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||||
|
ageField.setText("nope");
|
||||||
|
ViewModel viewModel = loaded.getViewModel();
|
||||||
|
Message first = viewModel.getMessages().get(0);
|
||||||
|
ageField.setText("still-bad");
|
||||||
|
assertEquals(1, viewModel.getMessages().size());
|
||||||
|
assertSame(first, viewModel.getMessages().get(0));
|
||||||
|
assertEquals("not a whole number: still-bad", first.getText());
|
||||||
|
assertEquals(Integer.valueOf(42), person.getAge());
|
||||||
|
assertEquals("years:42", ageField.getText());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aValidAgeRemovesTheFailureMessage() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAge(42);
|
||||||
|
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||||
|
ageField.setText("nope");
|
||||||
|
ageField.setText("years:11");
|
||||||
|
assertEquals(Integer.valueOf(11), person.getAge());
|
||||||
|
assertEquals("years:11", ageField.getText());
|
||||||
|
assertEquals(0, loaded.getViewModel().getMessages().size());
|
||||||
|
assertNull(loaded.getViewModel().getLatestMessage());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void developerInfoStaysLatestAndIsNotLogged() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAge(42);
|
||||||
|
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||||
|
ViewModel viewModel = loaded.getViewModel();
|
||||||
|
ageField.setText("nope");
|
||||||
|
String log = captureErr(() -> viewModel.addMessage(
|
||||||
|
new Message(Message.Severity.INFO, "developer-info-notice", "form")));
|
||||||
|
assertEquals(2, viewModel.getMessages().size());
|
||||||
|
assertEquals("developer-info-notice", viewModel.getLatestMessage().getText());
|
||||||
|
assertEquals(Message.Severity.INFO, viewModel.getLatestMessage().getSeverity());
|
||||||
|
assertFalse(log.contains("developer-info-notice"));
|
||||||
|
ageField.setText("still-bad");
|
||||||
|
assertEquals(2, viewModel.getMessages().size());
|
||||||
|
assertEquals("not a whole number: still-bad", viewModel.getMessages().get(0).getText());
|
||||||
|
assertEquals("developer-info-notice", viewModel.getLatestMessage().getText());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void errorAndFatalMessagesAreWrittenToTheLog() throws Exception {
|
||||||
|
onFx(() -> {
|
||||||
|
Person person = new Person("Ada");
|
||||||
|
person.setAge(42);
|
||||||
|
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||||
|
assertNotNull(fxml);
|
||||||
|
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||||
|
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||||
|
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||||
|
ViewModel viewModel = loaded.getViewModel();
|
||||||
|
String log = captureErr(() -> {
|
||||||
|
viewModel.addMessage(new Message(Message.Severity.WARN, "developer-warn-notice", "form"));
|
||||||
|
viewModel.addMessage(new Message(Message.Severity.ERROR, "developer-error-notice", "form"));
|
||||||
|
viewModel.addMessage(new Message(Message.Severity.FATAL, "developer-fatal-notice", "form"));
|
||||||
|
ageField.setText("nope");
|
||||||
|
ageField.setText("still-bad");
|
||||||
|
});
|
||||||
|
assertFalse(log.contains("developer-warn-notice"));
|
||||||
|
assertTrue(log.contains("developer-error-notice"));
|
||||||
|
assertTrue(log.contains("developer-fatal-notice"));
|
||||||
|
assertTrue(log.contains("not a whole number: nope"));
|
||||||
|
assertTrue(log.contains("not a whole number: still-bad"));
|
||||||
|
assertEquals("not a whole number: still-bad", viewModel.getLatestMessage().getText());
|
||||||
|
assertEquals(4, viewModel.getMessages().size());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String captureErr(FxAction action) throws Exception {
|
||||||
|
PrintStream previous = System.err;
|
||||||
|
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||||
|
PrintStream captured = new PrintStream(buffer, true, StandardCharsets.UTF_8);
|
||||||
|
System.setErr(captured);
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
captured.flush();
|
||||||
|
return buffer.toString(StandardCharsets.UTF_8);
|
||||||
|
} finally {
|
||||||
|
System.setErr(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface FxAction {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package de.neitzel.fx.view.fixture;
|
||||||
|
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service whose text a converter must show, so a test can see that injection ran.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
public class AgeUnit {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the prefix written in front of an age.
|
||||||
|
*
|
||||||
|
* @return the prefix
|
||||||
|
*/
|
||||||
|
public String prefix() {
|
||||||
|
return "years:";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.neitzel.fx.view.fixture;
|
||||||
|
|
||||||
|
import de.neitzel.fx.view.Converter;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an age using the prefix from {@link AgeUnit}.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
public class PrefixedAgeConverter implements Converter {
|
||||||
|
|
||||||
|
private final AgeUnit ageUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a converter that reads its prefix from the injected service.
|
||||||
|
*
|
||||||
|
* @param ageUnit the service that supplies the prefix
|
||||||
|
*/
|
||||||
|
@Inject
|
||||||
|
public PrefixedAgeConverter(AgeUnit ageUnit) {
|
||||||
|
this.ageUnit = ageUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the injected prefix in front of the age.
|
||||||
|
*
|
||||||
|
* @param attributeValue the age
|
||||||
|
* @return the control text
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Object toControlValue(Object attributeValue) {
|
||||||
|
return ageUnit.prefix() + attributeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads an age that starts with the injected prefix.
|
||||||
|
*
|
||||||
|
* @param controlValue the control text
|
||||||
|
* @return the age
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Object toAttributeValue(Object controlValue) {
|
||||||
|
String text = controlValue == null ? "" : controlValue.toString();
|
||||||
|
String prefix = ageUnit.prefix();
|
||||||
|
if (!text.startsWith(prefix)) {
|
||||||
|
throw new IllegalArgumentException("not a whole number: " + text);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.valueOf(text.substring(prefix.length()));
|
||||||
|
} catch (NumberFormatException failure) {
|
||||||
|
throw new IllegalArgumentException("not a whole number: " + text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.neitzel.fx.view.fixture;
|
||||||
|
|
||||||
|
import de.neitzel.fx.view.Converter;
|
||||||
|
import jakarta.inject.Singleton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows an age and has no reverse conversion.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
public class ShownAgeConverter implements Converter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the age into label text.
|
||||||
|
*
|
||||||
|
* @param attributeValue the age
|
||||||
|
* @return the label text
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Object toControlValue(Object attributeValue) {
|
||||||
|
return "shown " + attributeValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.DirectBinding?>
|
||||||
|
|
||||||
|
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||||
|
<fx:define>
|
||||||
|
<BindingList>
|
||||||
|
<DirectBinding control="ageField" property="text" path="age" direction="both"
|
||||||
|
converter="de.neitzel.fx.view.fixture.PrefixedAgeConverter"/>
|
||||||
|
<DirectBinding control="ageLabel" property="text" path="age" direction="view"
|
||||||
|
converter="de.neitzel.fx.view.fixture.ShownAgeConverter"/>
|
||||||
|
</BindingList>
|
||||||
|
</fx:define>
|
||||||
|
<TextField fx:id="ageField" id="ageField"/>
|
||||||
|
<Label fx:id="ageLabel" id="ageLabel"/>
|
||||||
|
</VBox>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import javafx.scene.control.TextField?>
|
||||||
|
<?import javafx.scene.layout.VBox?>
|
||||||
|
<?import de.neitzel.fx.view.BindingList?>
|
||||||
|
<?import de.neitzel.fx.view.DirectBinding?>
|
||||||
|
|
||||||
|
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||||
|
<fx:define>
|
||||||
|
<BindingList>
|
||||||
|
<DirectBinding control="ageField" property="text" path="age" direction="both"
|
||||||
|
converter="de.neitzel.fx.view.fixture.PrefixedAgeConverter"/>
|
||||||
|
</BindingList>
|
||||||
|
</fx:define>
|
||||||
|
<TextField fx:id="ageField" id="ageField"/>
|
||||||
|
</VBox>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import javafx.scene.control.TextField?>
|
||||||
|
<?import javafx.scene.layout.VBox?>
|
||||||
|
<?import de.neitzel.fx.view.BindingList?>
|
||||||
|
<?import de.neitzel.fx.view.DirectBinding?>
|
||||||
|
|
||||||
|
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||||
|
<fx:define>
|
||||||
|
<BindingList>
|
||||||
|
<DirectBinding control="ageField" property="text" path="age" direction="model"
|
||||||
|
converter="de.neitzel.fx.view.fixture.PrefixedAgeConverter"/>
|
||||||
|
</BindingList>
|
||||||
|
</fx:define>
|
||||||
|
<TextField fx:id="ageField" id="ageField"/>
|
||||||
|
</VBox>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import javafx.scene.control.Label?>
|
||||||
|
<?import javafx.scene.layout.VBox?>
|
||||||
|
<?import de.neitzel.fx.view.BindingList?>
|
||||||
|
<?import de.neitzel.fx.view.DirectBinding?>
|
||||||
|
|
||||||
|
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||||
|
<fx:define>
|
||||||
|
<BindingList>
|
||||||
|
<DirectBinding control="ageLabel" property="text" path="age" direction="view"
|
||||||
|
converter="de.neitzel.fx.view.fixture.ShownAgeConverter"/>
|
||||||
|
</BindingList>
|
||||||
|
</fx:define>
|
||||||
|
<Label fx:id="ageLabel" id="ageLabel"/>
|
||||||
|
</VBox>
|
||||||
Reference in New Issue
Block a user