Complete JavaFX MVVM examples, bindings and library documentation

This commit is contained in:
2026-11-07 18:36:53 +01:00
parent c76f5b2a28
commit 8062b61ed6
110 changed files with 3808 additions and 220 deletions
@@ -17,6 +17,9 @@ import java.util.Map;
@DefaultProperty("elements")
public class BindingList {
/**
* The binding elements, in document order.
*/
private final List<Object> elements = new ArrayList<>();
/**
@@ -17,11 +17,33 @@ import java.util.Set;
*/
final class ChildMessageMirror implements MessageListener {
/**
* The parent that receives copies of the child's notices.
*/
private final ViewModel parent;
/**
* The Attribute that leads to the child. It is prepended to each copied source.
*/
private final String attribute;
/**
* The child whose notices are carried.
*/
private final ViewModel child;
/**
* Copies already carried, keyed by the identity of the child notice.
*/
private final Map<Message, Message> copies = new IdentityHashMap<>();
/**
* Creates a mirror. {@link #attach} registers it and copies notices already present.
*
* @param parent the parent ViewModel
* @param attribute the Attribute that leads to the child
* @param child the child ViewModel
*/
private ChildMessageMirror(ViewModel parent, String attribute, ViewModel child) {
this.parent = parent;
this.attribute = attribute;
@@ -97,6 +119,11 @@ final class ChildMessageMirror implements MessageListener {
parent.noteMessageText(copy);
}
/**
* Copies {@code message} onto the parent unless that copy is already there or would cycle.
*
* @param message the child notice
*/
private void carry(Message message) {
if (copies.containsKey(message) || repeats(message)) {
return;
@@ -18,6 +18,11 @@ public class Column {
*/
private String attribute;
/**
* The starting width, or {@code null} when this Column takes an equal share of the Table.
*/
private Double width;
/**
* Sets the header text.
*
@@ -53,4 +58,34 @@ public class Column {
public String getAttribute() {
return attribute;
}
/**
* Sets the starting width.
* <p>
* A Column with no starting width opens at an equal share of the Table. A drag can change the
* width later. A width that is not positive fails the load.
*
* @param width the starting width
*/
public void setWidth(double width) {
this.width = width;
}
/**
* Returns the starting width.
*
* @return the starting width, or {@code 0} when this Column takes an equal share
*/
public double getWidth() {
return width == null ? 0 : width;
}
/**
* Returns whether a starting width was set.
*
* @return {@code true} when {@link #setWidth(double)} has been called
*/
boolean hasWidth() {
return width != null;
}
}
@@ -24,6 +24,9 @@ import java.util.Map;
*/
final class Components {
/**
* Prevents instantiation.
*/
private Components() {
}
@@ -86,6 +89,13 @@ final class Components {
modelProperty(component).setValue(model);
}
/**
* Loads {@code ClassName.fxml} with the Component as root and Controller.
*
* @param component the Component
* @param context the container that creates nested Components
* @return the loaded Component
*/
private static Object load(Object component, ApplicationContext context) {
Class<?> type = component.getClass();
String document = type.getSimpleName() + ".fxml";
@@ -109,6 +119,14 @@ final class Components {
}
}
/**
* Runs the Component's bindings once, when {@code model} first becomes non-null.
*
* @param component the Component
* @param lists the binding lists captured from its document
* @param namespace objects addressed by {@code fx:id}
* @param context the container that creates a named Converter
*/
private static void watchModel(Object component, List<BindingList> lists, Map<String, Object> namespace,
ApplicationContext context) {
Property<Object> model = modelProperty(component);
@@ -129,6 +147,12 @@ final class Components {
});
}
/**
* Returns the Component's {@code model} property.
*
* @param component the Component
* @return the property
*/
@SuppressWarnings("unchecked")
private static Property<Object> modelProperty(Object component) {
try {
@@ -10,8 +10,19 @@ package de.neitzel.fx.view;
*/
public class ComputedBinding {
/**
* The {@code fx:id} of the control that receives the method result.
*/
private String control;
/**
* The control property that receives the method result, such as {@code text}.
*/
private String property;
/**
* The Controller method, written as {@code #method}.
*/
private String method;
/**
@@ -16,6 +16,9 @@ import java.util.Map;
*/
final class ComputedBindingApplier {
/**
* Prevents instantiation.
*/
private ComputedBindingApplier() {
}
@@ -52,6 +55,16 @@ final class ComputedBindingApplier {
}
}
/**
* Returns a refresh that calls the Controller method and writes the result.
* <p>
* A refresh that is already running does not start again, so writing the target does not loop.
*
* @param controller the Controller
* @param methodValue the method, written as {@code #method}
* @param target the control property
* @return the refresh
*/
private static Runnable refresh(Object controller, String methodValue, Property<Object> target) {
boolean[] running = {false};
return () -> {
@@ -67,10 +80,24 @@ final class ComputedBindingApplier {
};
}
/**
* Watches JavaFX properties on controls under {@code root}, other than {@code target}.
*
* @param root the scene-graph root
* @param target the property the method writes, which is not watched
* @param refresh the refresh to run when a watched property changes
*/
private static void watchControls(Parent root, Property<?> target, Runnable refresh) {
watchNode(root, target, refresh);
}
/**
* Watches one control and walks its children.
*
* @param node the node
* @param target the property the method writes, which is not watched
* @param refresh the refresh to run when a watched property changes
*/
private static void watchNode(Node node, Property<?> target, Runnable refresh) {
if (node instanceof Control control) {
watchProperties(control, target, refresh);
@@ -82,6 +109,13 @@ final class ComputedBindingApplier {
}
}
/**
* Listens to every JavaFX property on {@code bean} except {@code target}.
*
* @param bean the control
* @param target the property the method writes
* @param refresh the refresh to run when a watched property changes
*/
@SuppressWarnings("unchecked")
private static void watchProperties(Object bean, Property<?> target, Runnable refresh) {
ChangeListener<Object> listener = (observable, oldValue, newValue) -> refresh.run();
@@ -99,6 +133,13 @@ final class ComputedBindingApplier {
}
}
/**
* Invokes a no-argument property method.
*
* @param bean the object that declares the property
* @param method the property method
* @return the property
*/
private static Object readProperty(Object bean, Method method) {
try {
return method.invoke(bean);
@@ -107,6 +148,13 @@ final class ComputedBindingApplier {
}
}
/**
* Returns the writable JavaFX property named on the control.
*
* @param control the control
* @param propertyName the property name, such as {@code text}
* @return the property
*/
@SuppressWarnings("unchecked")
private static Property<Object> controlProperty(Object control, String propertyName) {
String methodName = propertyName + "Property";
@@ -125,6 +173,13 @@ final class ComputedBindingApplier {
}
}
/**
* Calls the Controller method named by {@code methodValue}.
*
* @param controller the Controller
* @param methodValue the method, written as {@code #method}
* @return the method result
*/
private static Object invoke(Object controller, String methodValue) {
if (controller == null) {
throw new IllegalArgumentException("No controller for " + methodValue);
@@ -140,6 +195,12 @@ final class ComputedBindingApplier {
}
}
/**
* Drops a leading {@code #} from an FXML method value.
*
* @param methodValue the method value
* @return the method name
*/
private static String methodName(String methodValue) {
if (methodValue != null && methodValue.startsWith("#")) {
return methodValue.substring(1);
@@ -12,10 +12,29 @@ package de.neitzel.fx.view;
*/
public class DirectBinding {
/**
* The {@code fx:id} of the control.
*/
private String control;
/**
* The control property, such as {@code text}.
*/
private String property;
/**
* The Path. One name is an Attribute of this View's ViewModel.
*/
private String path;
/**
* The direction: {@code view}, {@code model}, or {@code both}.
*/
private String direction;
/**
* The Converter class name, or {@code null} when values pass through.
*/
private String converter;
/**
@@ -14,6 +14,9 @@ import java.util.Map;
*/
final class DirectBindingApplier {
/**
* Prevents instantiation.
*/
private DirectBindingApplier() {
}
@@ -68,6 +71,13 @@ final class DirectBindingApplier {
}
}
/**
* Aligns a plain Attribute in the named direction.
*
* @param controlProperty the control property
* @param attribute the Attribute
* @param direction {@code view}, {@code model}, or {@code both}
*/
private static void alignPlain(Property<?> controlProperty, Property<?> attribute, String direction) {
switch (direction) {
case "both" -> alignBoth(controlProperty, attribute);
@@ -84,11 +94,35 @@ final class DirectBindingApplier {
* name as the source, so a parent only carries the extended copy.
*/
private static final class Resolved {
/**
* The Attribute the Path names.
*/
private final Property<?> attribute;
/**
* Whether that Attribute is complex.
*/
private final boolean complex;
/**
* The ViewModel that owns the Attribute. A conversion failure is recorded here.
*/
private final ViewModel owner;
/**
* The last segment of the Path, used as the failure source.
*/
private final String name;
/**
* Records one resolved Path.
*
* @param attribute the Attribute
* @param complex whether the Attribute is complex
* @param owner the ViewModel that owns it
* @param name the last segment
*/
private Resolved(Property<?> attribute, boolean complex, ViewModel owner, String name) {
this.attribute = attribute;
this.complex = complex;
@@ -165,6 +199,13 @@ final class DirectBindingApplier {
ViewModels.of(instance, requested);
}
/**
* Follows a Path through the Model instances behind the ViewModels.
*
* @param viewModel the ViewModel for this View
* @param path the Path
* @return the Model instance at the end of the Path, or {@code null} when a step is null
*/
private static Object instanceAt(Object viewModel, String path) {
if (!(viewModel instanceof ViewModel root)) {
return null;
@@ -179,6 +220,13 @@ final class DirectBindingApplier {
return current;
}
/**
* Reads one JavaBean attribute through {@code get} or {@code is}.
*
* @param bean the Model instance
* @param name the attribute name
* @return the current value
*/
private static Object readAttribute(Object bean, String name) {
String suffix = Character.toUpperCase(name.charAt(0)) + name.substring(1);
for (String prefix : List.of("get", "is")) {
@@ -194,6 +242,13 @@ final class DirectBindingApplier {
throw new IllegalArgumentException("No read-write attribute for path \"" + name + "\"");
}
/**
* Returns the JavaFX property named on a ViewModel or a replacement.
*
* @param target the ViewModel or replacement
* @param name the property name
* @return the property, or {@code null} when that name is not declared
*/
private static Property<?> propertyOf(Object target, String name) {
if (target instanceof ViewModel viewModel) {
return viewModel.property(name);
@@ -201,10 +256,23 @@ final class DirectBindingApplier {
return NamedProperties.get(target, name);
}
/**
* Reports a Path that is not a read-write Attribute.
*
* @param path the Path
* @return the failure
*/
private static IllegalArgumentException missing(String path) {
return new IllegalArgumentException("No read-write attribute for path \"" + path + "\"");
}
/**
* Asks the container for the named Converter.
*
* @param context the container
* @param className the Converter class name
* @return the Converter
*/
private static Converter converter(ApplicationContext context, String className) {
try {
Class<?> type = Class.forName(className);
@@ -245,6 +313,16 @@ final class DirectBindingApplier {
}
}
/**
* Writes the converted Attribute onto the control. A failure keeps the previous control value.
*
* @param control the control property
* @param attribute the Attribute
* @param converter the Converter
* @param owner the ViewModel that records a failure
* @param source the Attribute name used as the failure source
* @param link the guard against hearing this write
*/
private static void pushControl(Property<Object> control, Property<Object> attribute, Converter converter,
ViewModel owner, String source, Link link) {
try {
@@ -261,6 +339,16 @@ final class DirectBindingApplier {
}
}
/**
* Writes the converted control value onto the Attribute. A failure keeps the previous Attribute.
*
* @param control the control property
* @param attribute the Attribute
* @param converter the Converter
* @param owner the ViewModel that records a failure
* @param source the Attribute name used as the failure source
* @param link the guard against hearing this write
*/
private static void pushAttribute(Property<Object> control, Property<Object> attribute, Converter converter,
ViewModel owner, String source, Link link) {
try {
@@ -277,6 +365,12 @@ final class DirectBindingApplier {
}
}
/**
* Returns the exception message, or a fallback when it is missing.
*
* @param failure the conversion failure
* @return the text recorded on the ViewModel
*/
private static String failureText(RuntimeException failure) {
String text = failure.getMessage();
if (text == null || text.isEmpty()) {
@@ -289,9 +383,20 @@ final class DirectBindingApplier {
* Guards one converted binding against hearing its own writes.
*/
private static final class Link {
/**
* Whether this binding is currently writing one side, so the other listener stays quiet.
*/
private boolean applying;
}
/**
* Returns the writable JavaFX property named on the control.
*
* @param control the control
* @param propertyName the property name, such as {@code text}
* @return the property
*/
private static Property<?> controlProperty(Object control, String propertyName) {
String methodName = propertyName + "Property";
try {
@@ -309,6 +414,15 @@ final class DirectBindingApplier {
}
}
/**
* Keeps a complex Attribute and the control aligned in both directions.
* <p>
* The control receives the Model instance. A later control value is stored as that instance's
* ViewModel.
*
* @param controlProperty the control property
* @param attribute the complex Attribute
*/
@SuppressWarnings("unchecked")
private static void alignBothComplex(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
@@ -328,6 +442,12 @@ final class DirectBindingApplier {
});
}
/**
* Shows a complex Attribute on the control and follows later changes. The control does not write back.
*
* @param controlProperty the control property
* @param attribute the complex Attribute
*/
@SuppressWarnings("unchecked")
private static void alignTowardViewComplex(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
@@ -336,6 +456,12 @@ final class DirectBindingApplier {
source.addListener((observable, oldValue, newValue) -> control.setValue(modelBehind(newValue)));
}
/**
* Shows a complex Attribute once, then writes the Attribute when the control changes.
*
* @param controlProperty the control property
* @param attribute the complex Attribute
*/
@SuppressWarnings("unchecked")
private static void alignTowardModelComplex(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
@@ -349,6 +475,12 @@ final class DirectBindingApplier {
});
}
/**
* Returns the Model instance stored in a complex Attribute.
*
* @param value the Attribute value, a ViewModel or {@code null}
* @return the Model instance, or {@code null}
*/
private static Object modelBehind(Object value) {
if (value instanceof ViewModel viewModel) {
return viewModel.model();
@@ -356,16 +488,34 @@ final class DirectBindingApplier {
return null;
}
/**
* Sets the control property to the shared list. Adds and removes are then visible on both sides.
*
* @param controlProperty the control property, such as {@code items}
* @param values the list the ViewModel and the Model share
*/
@SuppressWarnings("unchecked")
private static void assignList(Property<?> controlProperty, ObservableList<?> values) {
((Property<Object>) controlProperty).setValue(values);
}
/**
* Binds the control and the Attribute in both directions.
*
* @param controlProperty the control property
* @param attribute the Attribute
*/
@SuppressWarnings("unchecked")
private static void alignBoth(Property<?> controlProperty, Property<?> attribute) {
((Property<Object>) controlProperty).bindBidirectional((Property<Object>) attribute);
}
/**
* Shows the Attribute and follows later changes. Edits to the control stay on the control.
*
* @param controlProperty the control property
* @param attribute the Attribute
*/
@SuppressWarnings("unchecked")
private static void alignTowardView(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
@@ -374,6 +524,12 @@ final class DirectBindingApplier {
source.addListener((observable, oldValue, newValue) -> control.setValue(newValue));
}
/**
* Shows the current value, then writes the Attribute when the control changes.
*
* @param controlProperty the control property
* @param attribute the Attribute
*/
@SuppressWarnings("unchecked")
private static void alignTowardModel(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty;
@@ -18,8 +18,14 @@ import java.net.URL;
*/
final class FxmlControllers {
/**
* The FXML namespace used by {@code fx:controller}.
*/
private static final String FXML_NAMESPACE = "http://javafx.com/fxml";
/**
* Prevents instantiation.
*/
private FxmlControllers() {
}
@@ -42,6 +48,12 @@ final class FxmlControllers {
}
}
/**
* Returns the {@code fx:controller} value on the document root.
*
* @param fxml the FXML document
* @return the class name, or {@code null} when the document names none
*/
private static String controllerName(URL fxml) {
try (InputStream input = fxml.openStream()) {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
@@ -17,11 +17,24 @@ import java.util.Set;
*/
final class LoadSession {
/**
* The sessions open on this thread. The last one is the outermost load.
*/
private static final ThreadLocal<Deque<LoadSession>> STACK = new ThreadLocal<>();
/**
* Binding lists created while this session is the current one, in document order.
*/
private final List<BindingList> bindingLists = new ArrayList<>();
/**
* Component classes already built during the outermost load.
*/
private final Set<Class<?>> built = new HashSet<>();
/**
* Prevents instantiation from outside this class.
*/
private LoadSession() {
}
@@ -8,7 +8,14 @@ import javafx.scene.Parent;
*/
public final class LoadedView {
/**
* The scene-graph root of the loaded View.
*/
private final Parent root;
/**
* The ViewModel the View is bound to, or a replacement that exposes JavaFX properties by name.
*/
private final Object viewModel;
/**
@@ -19,9 +19,24 @@ public final class Message {
FATAL
}
/**
* How serious the notice is.
*/
private final Severity severity;
/**
* The notice text. A binding failure updates this in place.
*/
private String text;
/**
* Where the notice came from, such as a binding Path.
*/
private final String source;
/**
* The child notice this one copies, or {@code null} when this notice was added directly.
*/
private Message origin;
/**
@@ -12,6 +12,9 @@ import javafx.scene.control.Label;
*/
public final class MessagePopup {
/**
* Prevents instantiation.
*/
private MessagePopup() {
}
@@ -12,6 +12,9 @@ import java.lang.reflect.Method;
*/
final class NamedProperties {
/**
* Prevents instantiation.
*/
private NamedProperties() {
}
@@ -9,8 +9,19 @@ package de.neitzel.fx.view;
*/
public class Repeat {
/**
* The {@code fx:id} of the pane that holds the Component children.
*/
private String pane;
/**
* The Component class name. Each element gets a new instance of that class.
*/
private String component;
/**
* The collection Attribute. The list holds the Model instances.
*/
private String collection;
/**
@@ -14,6 +14,9 @@ import java.util.Map;
*/
final class RepeatApplier {
/**
* Prevents instantiation.
*/
private RepeatApplier() {
}
@@ -47,6 +50,13 @@ final class RepeatApplier {
listen(pane, type, context, values);
}
/**
* Returns the collection Attribute named on the ViewModel.
*
* @param viewModel the ViewModel for this View
* @param name the collection Attribute
* @return the property, or {@code null} when the name is not an Attribute
*/
private static Property<?> collection(Object viewModel, String name) {
if (viewModel instanceof ViewModel model) {
return model.property(name);
@@ -54,6 +64,17 @@ final class RepeatApplier {
return NamedProperties.get(viewModel, name);
}
/**
* Adds and removes Component children as the collection changes.
* <p>
* A permutation or an element update is left as it is. An add or remove keeps the children
* in list order.
*
* @param pane the pane named by the repeat
* @param type the Component class
* @param context the container that creates each Component
* @param values the collection
*/
@SuppressWarnings("unchecked")
private static void listen(Pane pane, Class<?> type, ApplicationContext context, ObservableList<?> values) {
ObservableList<Object> elements = (ObservableList<Object>) values;
@@ -74,12 +95,26 @@ final class RepeatApplier {
});
}
/**
* Creates one Component and sets its model to {@code element}.
*
* @param type the Component class
* @param context the container
* @param element the Model instance
* @return the loaded Component
*/
private static Node child(Class<?> type, ApplicationContext context, Object element) {
Object component = Components.fresh(type, context);
Components.setModel(component, element);
return (Node) component;
}
/**
* Returns the Component class named by the repeat.
*
* @param className the class name
* @return the class
*/
private static Class<?> componentType(String className) {
try {
return Class.forName(className);
@@ -8,6 +8,9 @@ package de.neitzel.fx.view;
*/
final class SuppliedController implements HasViewModel {
/**
* The ViewModel handed over before the bindings run.
*/
private ViewModel viewModel;
/**
+117 -11
View File
@@ -47,8 +47,10 @@ import java.util.Set;
* A collection shown with one Component for each element.
* <p>
* The developer places the Table in the View and names the collection Attribute, the Row
* class, and each Column. Filter and Sort change which Rows are showing and in what order.
* class, and each Column. A Column may name a starting width; otherwise the Columns open at
* equal widths. Filter and Sort change which Rows are showing and in what order.
* The collection keeps its own order. The body scrolls vertically and the header stays put.
* Controls after the Column fields stay at their own width.
*/
@DefaultProperty("columns")
public class Table extends VBox {
@@ -202,6 +204,9 @@ public class Table extends VBox {
* One Filter per Column. A null entry means that Column has no Filter yet.
*/
private final List<ValueFilter> valueFilters = new ArrayList<>();
/**
* Index of the Column whose popup is open, or {@code -1} when no popup is open.
*/
private int popupIndex = -1;
/**
@@ -410,6 +415,9 @@ public class Table extends VBox {
if (!seen.add(column.getAttribute())) {
throw new IllegalArgumentException("Attribute \"" + column.getAttribute() + "\" is used twice");
}
if (column.hasWidth() && !(column.getWidth() > 0 && column.getWidth() < Double.POSITIVE_INFINITY)) {
throw new IllegalArgumentException("Column \"" + column.getHeader() + "\" needs a positive width");
}
}
}
@@ -1344,40 +1352,98 @@ public class Table extends VBox {
}
/**
* Gives each Column its width, and the same width to each field in that order.
* Gives each Column its width.
* <p>
* Columns with no starting width share the Table width equally. A starting width is kept, and
* a later change of the Table's width does not redistribute the Columns. A starting width
* narrower than the header text opens at the header text.
*/
private void ensureWidths() {
if (columnWidths != null && columnWidths.length == columns.size()) {
return;
}
columnWidths = new double[columns.size()];
if (!anyStartingWidth()) {
java.util.Arrays.fill(columnWidths, tableWidth() / columns.size());
return;
}
double specified = 0;
int open = 0;
for (int index = 0; index < columns.size(); index++) {
Column column = columns.get(index);
if (column.hasWidth()) {
double chosen = Math.max(column.getWidth(), minimumWidth(index));
columnWidths[index] = chosen;
specified += chosen;
} else {
open++;
}
}
if (open == 0) {
return;
}
double share = (tableWidth() - specified) / open;
for (int index = 0; index < columns.size(); index++) {
if (!columns.get(index).hasWidth()) {
columnWidths[index] = Math.max(share, minimumWidth(index));
}
}
}
/**
* Returns whether any Column names a starting width.
*
* @return {@code true} when at least one Column has a starting width
*/
private boolean anyStartingWidth() {
for (Column column : columns) {
if (column.hasWidth()) {
return true;
}
}
return false;
}
/**
* Returns the width the equal shares are taken from.
*
* @return the Table width, or a fallback when the Table has not been laid out
*/
private double tableWidth() {
double width = getWidth() > 0 ? getWidth() : getPrefWidth();
if (width <= 0) {
width = columns.size() * 80.0;
return columns.size() * 80.0;
}
columnWidths = new double[columns.size()];
java.util.Arrays.fill(columnWidths, width / columns.size());
return width;
}
/**
* Applies the stored Column widths to the header and the fields.
* <p>
* The first fields, one per Column, take those widths. Controls after them keep their own
* size. Spacing on an {@link HBox} row is repeated between the header cells.
*/
private void applyWidths() {
if (columns.isEmpty() || columnWidths == null) {
return;
}
double sum = 0;
double columnSum = 0;
for (double width : columnWidths) {
sum += width;
columnSum += width;
}
header.setPrefWidth(sum);
header.setMinWidth(sum);
double spacing = rowSpacing();
header.setSpacing(spacing);
double headerWidth = columnSum + spacing * Math.max(0, columns.size() - 1);
header.setPrefWidth(headerWidth);
header.setMinWidth(headerWidth);
for (int index = 0; index < header.getChildren().size(); index++) {
fixWidth((Region) header.getChildren().get(index), columnWidths[index]);
}
for (Node rowNode : body.getChildren()) {
double rowWidth = rowNode instanceof HBox box ? widthOf(box, columnSum) : headerWidth;
if (rowNode instanceof Region region) {
region.setPrefWidth(sum);
region.setMinWidth(sum);
region.setPrefWidth(rowWidth);
region.setMinWidth(rowWidth);
}
if (rowNode instanceof Parent parent) {
int index = 0;
@@ -1394,6 +1460,46 @@ public class Table extends VBox {
}
}
/**
* Returns the spacing of a Row, so the header can use the same gaps.
*
* @return the spacing, or {@code 0} when no Row is an {@link HBox}
*/
private double rowSpacing() {
for (Node row : body.getChildren()) {
if (row instanceof HBox box) {
return box.getSpacing();
}
}
for (Node row : rows.values()) {
if (row instanceof HBox box) {
return box.getSpacing();
}
}
return 0;
}
/**
* Returns the width that fits the Columns and any controls after them.
*
* @param box the Row
* @param columnSum the sum of the Column widths
* @return the Row width
*/
private double widthOf(HBox box, double columnSum) {
double width = columnSum + box.getInsets().getLeft() + box.getInsets().getRight();
if (box.getChildren().size() > 1) {
width += box.getSpacing() * (box.getChildren().size() - 1);
}
for (int index = columnWidths.length; index < box.getChildren().size(); index++) {
Node child = box.getChildren().get(index);
if (child.isManaged() && child instanceof Region region) {
width += region.prefWidth(-1);
}
}
return width;
}
/**
* Fixes one node's width.
*
@@ -77,17 +77,59 @@ import java.util.function.Consumer;
*/
public class ViewModel {
/**
* Receives error and fatal notices. Info and warn are not logged.
*/
private static final Logger LOGGER = LoggerFactory.getLogger(ViewModel.class);
/**
* The Model instance this ViewModel was built for.
*/
private final Object model;
/**
* JavaFX properties keyed by Attribute name.
*/
private final Map<String, Property<?>> properties = new HashMap<>();
/**
* Attribute names whose value is a nested ViewModel.
*/
private final Set<String> complexAttributes = new HashSet<>();
/**
* Notices on this ViewModel, oldest first.
*/
private final List<Message> messages = new ArrayList<>();
/**
* The current conversion failure for an Attribute name, so a later failure updates it in place.
*/
private final Map<String, Message> bindingFailures = new HashMap<>();
/**
* Listeners told when a notice is added, removed, or updated.
*/
private final List<MessageListener> messageListeners = new ArrayList<>();
/**
* Mirrors that carry a child ViewModel's notices, keyed by the Attribute that leads to the child.
*/
private final Map<String, ChildMessageMirror> childMirrors = new HashMap<>();
/**
* The text of {@link #latestMessage}, bound as {@code latestMessage}.
*/
private final StringProperty latestMessageText = new SimpleStringProperty();
/**
* Callbacks registered by {@link MessagePopup#showNewMessages} and similar callers.
*/
private final List<Consumer<Message>> messageAdded = new ArrayList<>();
/**
* The notice most recently added, or {@code null} when there is none.
*/
private Message latestMessage;
/**
@@ -191,6 +233,12 @@ public class ViewModel {
return NamedProperties.get(this, name);
}
/**
* Returns the JavaBean attributes of {@code type}, excluding those declared on {@link Object}.
*
* @param type the Model class
* @return the attributes
*/
private static List<PropertyDescriptor> descriptors(Class<?> type) {
try {
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
@@ -200,6 +248,12 @@ public class ViewModel {
}
}
/**
* Returns whether {@code type} itself has a read-write attribute and is not a simple value.
*
* @param type the declared attribute type
* @return {@code true} when the attribute is complex
*/
private static boolean isComplex(Class<?> type) {
if (simpleValue(type)) {
return false;
@@ -214,6 +268,15 @@ public class ViewModel {
return false;
}
/**
* Returns whether {@code type} stays a simple value even when it has setters.
* <p>
* Primitives, enums, arrays, strings, numbers, booleans, collections, maps, dates, and
* {@code java.time} types are simple.
*
* @param type the declared attribute type
* @return {@code true} when the attribute is not complex
*/
private static boolean simpleValue(Class<?> type) {
if (type.isPrimitive() || type.isEnum() || type.isArray()
|| type == String.class || type == Boolean.class) {
@@ -232,6 +295,15 @@ public class ViewModel {
return packaged != null && packaged.getName().startsWith("java.time");
}
/**
* Creates the JavaFX property for a declared attribute type.
* <p>
* A null number or boolean leaves the primitive property at its default.
*
* @param type the declared type
* @param value the current value, or {@code null}
* @return the property
*/
private static Property<?> createProperty(Class<?> type, Object value) {
if (type == String.class) {
return new SimpleStringProperty((String) value);
@@ -254,6 +326,12 @@ public class ViewModel {
return new SimpleObjectProperty<>(value);
}
/**
* Creates an integer property. A null value leaves it at zero.
*
* @param value the current value, or {@code null}
* @return the property
*/
private static IntegerProperty integerProperty(Object value) {
SimpleIntegerProperty property = new SimpleIntegerProperty();
if (value != null) {
@@ -262,6 +340,12 @@ public class ViewModel {
return property;
}
/**
* Creates a long property. A null value leaves it at zero.
*
* @param value the current value, or {@code null}
* @return the property
*/
private static LongProperty longProperty(Object value) {
SimpleLongProperty property = new SimpleLongProperty();
if (value != null) {
@@ -270,6 +354,12 @@ public class ViewModel {
return property;
}
/**
* Creates a float property. A null value leaves it at zero.
*
* @param value the current value, or {@code null}
* @return the property
*/
private static FloatProperty floatProperty(Object value) {
SimpleFloatProperty property = new SimpleFloatProperty();
if (value != null) {
@@ -278,6 +368,12 @@ public class ViewModel {
return property;
}
/**
* Creates a double property. A null value leaves it at zero.
*
* @param value the current value, or {@code null}
* @return the property
*/
private static DoubleProperty doubleProperty(Object value) {
SimpleDoubleProperty property = new SimpleDoubleProperty();
if (value != null) {
@@ -286,6 +382,12 @@ public class ViewModel {
return property;
}
/**
* Creates a boolean property. A null value leaves it at {@code false}.
*
* @param value the current value, or {@code null}
* @return the property
*/
private static BooleanProperty booleanProperty(Object value) {
SimpleBooleanProperty property = new SimpleBooleanProperty();
if (value != null) {
@@ -294,6 +396,13 @@ public class ViewModel {
return property;
}
/**
* Reads the Attribute through its getter.
*
* @param getter the getter
* @param name the Attribute name, used when the read fails
* @return the current value
*/
private Object read(Method getter, String name) {
try {
return getter.invoke(model);
@@ -302,6 +411,13 @@ public class ViewModel {
}
}
/**
* Writes the Attribute when the property changes.
*
* @param property the JavaFX property
* @param setter the setter
* @param name the Attribute name, used when the write fails
*/
private void watch(Property<?> property, Method setter, String name) {
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
}
@@ -314,14 +430,35 @@ public class ViewModel {
*/
private static final class LazyComplex extends SimpleObjectProperty<Object> {
/**
* The Model instance, kept until the nested ViewModel is built.
*/
private final Object current;
/**
* Whether {@link #get()} has already built the nested ViewModel.
*/
private boolean realized;
/**
* Whether the change listener is still being attached. Reads during that time are ignored.
*/
private boolean attaching = true;
/**
* Holds the Model instance until the nested ViewModel is built.
*
* @param current the Model instance, or {@code null}
*/
private LazyComplex(Object current) {
this.current = current;
}
/**
* Builds the nested ViewModel on the first read after the listener is attached.
*
* @return the nested ViewModel, or {@code null} when the Attribute is null
*/
@Override
public Object get() {
if (!attaching && !realized) {
@@ -331,15 +468,31 @@ public class ViewModel {
return super.get();
}
/**
* Allows the next {@link #get()} to build the nested ViewModel.
*/
private void attached() {
attaching = false;
}
/**
* Returns whether this attribute still holds {@code instance} and has not built its ViewModel.
*
* @param instance the Model instance just selected elsewhere
* @return {@code true} when a shared read should realize this attribute too
*/
private boolean pending(Object instance) {
return !realized && current == instance;
}
}
/**
* Writes the Model instance behind a complex Attribute and carries that child's notices.
*
* @param property the JavaFX property
* @param setter the setter
* @param name the Attribute name
*/
private void watchComplex(Property<?> property, Method setter, String name) {
property.addListener((observable, oldValue, newValue) -> {
if (oldValue != newValue) {
@@ -360,6 +513,10 @@ public class ViewModel {
* @param name the Attribute that leads to the child
* @param child the child ViewModel
*/
private void attachChild(String name, ViewModel child) {
childMirrors.put(name, ChildMessageMirror.attach(this, name, child));
}
/**
* Realizes every other Complex attribute that holds the same Model instance.
* <p>
@@ -379,10 +536,6 @@ public class ViewModel {
}
}
private void attachChild(String name, ViewModel child) {
childMirrors.put(name, ChildMessageMirror.attach(this, name, child));
}
/**
* Drops the copies carried for an Attribute and stops following that child.
*
@@ -395,6 +548,13 @@ public class ViewModel {
}
}
/**
* Writes the Attribute through its setter.
*
* @param setter the setter
* @param name the Attribute name, used when the write fails
* @param value the value
*/
private void write(Method setter, String name, Object value) {
try {
setter.invoke(model, value);
@@ -607,6 +767,12 @@ public class ViewModel {
}
}
/**
* Returns whether the getter's collection holds simple values.
*
* @param getter the collection getter
* @return {@code true} when the element type is a simple value
*/
private static boolean isSimpleValueCollection(Method getter) {
if (!Collection.class.isAssignableFrom(getter.getReturnType())) {
return false;
@@ -615,6 +781,12 @@ public class ViewModel {
return element != null && isSimpleValue(element);
}
/**
* Returns whether the getter's collection holds Model instances.
*
* @param getter the collection getter
* @return {@code true} when the element type is complex
*/
private static boolean isModelCollection(Method getter) {
if (!Collection.class.isAssignableFrom(getter.getReturnType())) {
return false;
@@ -623,6 +795,12 @@ public class ViewModel {
return element != null && isComplex(element);
}
/**
* Returns the single type argument of a parameterized type.
*
* @param type the generic return type
* @return the element class, or {@code null} when it cannot be read
*/
private static Class<?> elementType(Type type) {
if (!(type instanceof ParameterizedType parameterized) || parameterized.getActualTypeArguments().length != 1) {
return null;
@@ -630,6 +808,12 @@ public class ViewModel {
return classOf(parameterized.getActualTypeArguments()[0]);
}
/**
* Returns the raw class of a type argument, including a wildcard's upper bound.
*
* @param type the type argument
* @return the class, or {@code null} when it cannot be read
*/
private static Class<?> classOf(Type type) {
if (type instanceof Class<?> raw) {
return raw;
@@ -643,6 +827,12 @@ public class ViewModel {
return null;
}
/**
* Returns whether a collection element stays a plain value rather than a ViewModel.
*
* @param type the element type
* @return {@code true} when the element is a simple value
*/
private static boolean isSimpleValue(Class<?> type) {
if (type == String.class || type == Boolean.class || type == boolean.class || type.isEnum()) {
return true;
@@ -659,6 +849,14 @@ public class ViewModel {
return TemporalAccessor.class.isAssignableFrom(type) || TemporalAmount.class.isAssignableFrom(type);
}
/**
* Wraps a collection in one {@link ObservableList} and stores it on the Model when the setter accepts it.
*
* @param setter the setter
* @param name the Attribute name
* @param current the current collection
* @return the list the View watches
*/
@SuppressWarnings("unchecked")
private Object shareSimpleList(Method setter, String name, Object current) {
if (!(current instanceof Collection<?> collection)) {
@@ -12,8 +12,14 @@ import java.util.Map;
*/
final class ViewModels {
/**
* ViewModels already built, keyed by Model identity.
*/
private static final Map<Object, Object> BY_INSTANCE = new IdentityHashMap<>();
/**
* Prevents instantiation.
*/
private ViewModels() {
}
@@ -75,6 +81,16 @@ final class ViewModels {
return annotation.value();
}
/**
* Constructs a ViewModel or a replacement with the Model instance.
* <p>
* A result that is not a {@link ViewModel} is registered here. A {@link ViewModel} registers
* itself from its constructor, before its Attributes are walked.
*
* @param model the Model instance
* @param viewModelClass the class to construct
* @return the constructed object
*/
private static Object construct(Object model, Class<?> viewModelClass) {
Constructor<?> constructor = constructor(model, viewModelClass);
constructor.setAccessible(true);
@@ -94,6 +110,13 @@ final class ViewModels {
}
}
/**
* Returns the constructor that takes the Model, preferring the most specific parameter type.
*
* @param model the Model instance
* @param viewModelClass the class to construct
* @return the constructor
*/
private static Constructor<?> constructor(Object model, Class<?> viewModelClass) {
Constructor<?> chosen = null;
for (Constructor<?> candidate : viewModelClass.getDeclaredConstructors()) {
@@ -24,8 +24,14 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.fail;
/**
* A child notice is carried onto its parent, with the attribute name prepended to the source.
*/
class CarryChildMessageTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
@@ -39,6 +45,9 @@ class CarryChildMessageTest {
}
}
/**
* A street notice on the address appears on the person as address street.
*/
@Test
void aStreetNoticeOnTheAddressAppearsOnThePersonAsAddressStreet() throws Exception {
onFx(() -> {
@@ -64,6 +73,9 @@ class CarryChildMessageTest {
});
}
/**
* A nested street notice reads as person address street.
*/
@Test
void aNestedStreetNoticeReadsAsPersonAddressStreet() throws Exception {
onFx(() -> {
@@ -90,6 +102,9 @@ class CarryChildMessageTest {
});
}
/**
* A bad street is owned by the address and carried as address street.
*/
@Test
void aBadStreetIsOwnedByTheAddressAndCarriedAsAddressStreet() throws Exception {
onFx(() -> {
@@ -120,6 +135,9 @@ class CarryChildMessageTest {
});
}
/**
* A fixed street drops the notice from the address and the person.
*/
@Test
void aFixedStreetDropsTheNoticeFromTheAddressAndThePerson() throws Exception {
onFx(() -> {
@@ -143,6 +161,9 @@ class CarryChildMessageTest {
});
}
/**
* Another bad street updates the carried notice in place.
*/
@Test
void anotherBadStreetUpdatesTheCarriedNoticeInPlace() throws Exception {
onFx(() -> {
@@ -186,6 +207,9 @@ class CarryChildMessageTest {
});
}
/**
* Clearing the address drops the carried notice and a new address brings its own.
*/
@Test
void clearingTheAddressDropsTheCarriedNoticeAndANewAddressBringsItsOwn() throws Exception {
onFx(() -> {
@@ -225,6 +249,9 @@ class CarryChildMessageTest {
});
}
/**
* Fixing the street leaves the persons own notice.
*/
@Test
void fixingTheStreetLeavesThePersonsOwnNotice() throws Exception {
onFx(() -> {
@@ -265,6 +292,9 @@ class CarryChildMessageTest {
});
}
/**
* A fixed nested street drops the notice up the path.
*/
@Test
void aFixedNestedStreetDropsTheNoticeUpThePath() throws Exception {
onFx(() -> {
@@ -293,6 +323,9 @@ class CarryChildMessageTest {
});
}
/**
* A shared address carries the street notice on home and on work.
*/
@Test
void aSharedAddressCarriesTheStreetNoticeOnHomeAndOnWork() throws Exception {
onFx(() -> {
@@ -318,6 +351,11 @@ class CarryChildMessageTest {
});
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
*/
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
@@ -349,8 +387,16 @@ class CarryChildMessageTest {
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -21,8 +21,14 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
* The controller and the view-model class are chosen from the document and then reused.
*/
class ChooseControllerAndViewModelTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
@@ -36,6 +42,9 @@ class ChooseControllerAndViewModelTest {
}
}
/**
* Label shows the name recorded when the view model was handed over.
*/
@Test
void labelShowsTheNameRecordedWhenTheViewModelWasHandedOver() throws Exception {
onFx(() -> {
@@ -49,6 +58,9 @@ class ChooseControllerAndViewModelTest {
});
}
/**
* Label shows the greeting injected into the controller.
*/
@Test
void labelShowsTheGreetingInjectedIntoTheController() throws Exception {
onFx(() -> {
@@ -62,6 +74,9 @@ class ChooseControllerAndViewModelTest {
});
}
/**
* Label shows the title from the named view model.
*/
@Test
void labelShowsTheTitleFromTheNamedViewModel() throws Exception {
onFx(() -> {
@@ -81,6 +96,9 @@ class ChooseControllerAndViewModelTest {
});
}
/**
* Label shows the badge from a replacement view model.
*/
@Test
void labelShowsTheBadgeFromAReplacementViewModel() throws Exception {
onFx(() -> {
@@ -100,6 +118,9 @@ class ChooseControllerAndViewModelTest {
});
}
/**
* Component label shows the caption from the named view model.
*/
@Test
void componentLabelShowsTheCaptionFromTheNamedViewModel() throws Exception {
onFx(() -> {
@@ -114,6 +135,9 @@ class ChooseControllerAndViewModelTest {
});
}
/**
* A later view that names a different class fails and leaves the first view.
*/
@Test
void aLaterViewThatNamesADifferentClassFailsAndLeavesTheFirstView() throws Exception {
onFx(() -> {
@@ -134,6 +158,9 @@ class ChooseControllerAndViewModelTest {
});
}
/**
* A later view with no annotation reuses the view model.
*/
@Test
void aLaterViewWithNoAnnotationReusesTheViewModel() throws Exception {
onFx(() -> {
@@ -155,6 +182,11 @@ class ChooseControllerAndViewModelTest {
});
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
*/
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
@@ -186,8 +218,16 @@ class ChooseControllerAndViewModelTest {
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -18,8 +18,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.fail;
/**
* A computed binding writes a controller method result and refreshes it when another property changes.
*/
class ComputeAValueTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
@@ -33,6 +39,9 @@ class ComputeAValueTest {
}
}
/**
* Computed binding shows the method result and leaves the model unchanged.
*/
@Test
void computedBindingShowsTheMethodResultAndLeavesTheModelUnchanged() throws Exception {
onFx(() -> {
@@ -49,6 +58,9 @@ class ComputeAValueTest {
});
}
/**
* Typing in another text field updates the computed label.
*/
@Test
void typingInAnotherTextFieldUpdatesTheComputedLabel() throws Exception {
onFx(() -> {
@@ -65,6 +77,9 @@ class ComputeAValueTest {
});
}
/**
* Setting a view model property updates the computed label.
*/
@Test
void settingAViewModelPropertyUpdatesTheComputedLabel() throws Exception {
onFx(() -> {
@@ -82,6 +97,9 @@ class ComputeAValueTest {
});
}
/**
* Changing the computed label does not run the method again.
*/
@Test
void changingTheComputedLabelDoesNotRunTheMethodAgain() throws Exception {
onFx(() -> {
@@ -97,6 +115,11 @@ class ComputeAValueTest {
});
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
*/
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
@@ -128,8 +151,16 @@ class ComputeAValueTest {
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -25,8 +25,14 @@ import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
* A converter failure keeps the previous value and records one error message.
*/
class ConvertAndRecordFailureTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
@@ -40,6 +46,9 @@ class ConvertAndRecordFailureTest {
}
}
/**
* Injected converter prefixes the age text.
*/
@Test
void injectedConverterPrefixesTheAgeText() throws Exception {
onFx(() -> {
@@ -54,6 +63,9 @@ class ConvertAndRecordFailureTest {
});
}
/**
* Typing a prefixed age updates the model.
*/
@Test
void typingAPrefixedAgeUpdatesTheModel() throws Exception {
onFx(() -> {
@@ -69,6 +81,9 @@ class ConvertAndRecordFailureTest {
});
}
/**
* Toward view loads when the reverse method is absent.
*/
@Test
void towardViewLoadsWhenTheReverseMethodIsAbsent() throws Exception {
onFx(() -> {
@@ -85,6 +100,9 @@ class ConvertAndRecordFailureTest {
});
}
/**
* Toward view converter follows a later age.
*/
@Test
void towardViewConverterFollowsALaterAge() throws Exception {
onFx(() -> {
@@ -103,6 +121,9 @@ class ConvertAndRecordFailureTest {
});
}
/**
* Toward model converts typed text without following the attribute.
*/
@Test
void towardModelConvertsTypedTextWithoutFollowingTheAttribute() throws Exception {
onFx(() -> {
@@ -123,6 +144,9 @@ class ConvertAndRecordFailureTest {
});
}
/**
* Bad age text keeps the previous int and one error message.
*/
@Test
void badAgeTextKeepsThePreviousIntAndOneErrorMessage() throws Exception {
onFx(() -> {
@@ -146,6 +170,9 @@ class ConvertAndRecordFailureTest {
});
}
/**
* Another bad age updates the same message.
*/
@Test
void anotherBadAgeUpdatesTheSameMessage() throws Exception {
onFx(() -> {
@@ -168,6 +195,9 @@ class ConvertAndRecordFailureTest {
});
}
/**
* A valid age removes the failure message.
*/
@Test
void aValidAgeRemovesTheFailureMessage() throws Exception {
onFx(() -> {
@@ -187,6 +217,9 @@ class ConvertAndRecordFailureTest {
});
}
/**
* Developer info stays latest and is not logged.
*/
@Test
void developerInfoStaysLatestAndIsNotLogged() throws Exception {
onFx(() -> {
@@ -212,6 +245,9 @@ class ConvertAndRecordFailureTest {
});
}
/**
* Error and fatal messages are written to the log.
*/
@Test
void errorAndFatalMessagesAreWrittenToTheLog() throws Exception {
onFx(() -> {
@@ -240,6 +276,11 @@ class ConvertAndRecordFailureTest {
});
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
*/
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
@@ -271,6 +312,12 @@ class ConvertAndRecordFailureTest {
}
}
/**
* Runs one check and returns what it wrote to standard error.
*
* @param action the check
* @return the text written to standard error
*/
private static String captureErr(FxAction action) throws Exception {
PrintStream previous = System.err;
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
@@ -285,8 +332,16 @@ class ConvertAndRecordFailureTest {
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -23,8 +23,14 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.fail;
/**
* A component edits one model instance, and a nested component edits the next.
*/
class EditAddressInAComponentTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
@@ -38,6 +44,9 @@ class EditAddressInAComponentTest {
}
}
/**
* Editing the street updates that address.
*/
@Test
void editingTheStreetUpdatesThatAddress() throws Exception {
onFx(() -> {
@@ -58,6 +67,9 @@ class EditAddressInAComponentTest {
});
}
/**
* Null address leaves the model unset and keeps the street text.
*/
@Test
void nullAddressLeavesTheModelUnsetAndKeepsTheStreetText() throws Exception {
onFx(() -> {
@@ -75,6 +87,9 @@ class EditAddressInAComponentTest {
});
}
/**
* Street label shows the injected prefix.
*/
@Test
void streetLabelShowsTheInjectedPrefix() throws Exception {
onFx(() -> {
@@ -90,6 +105,9 @@ class EditAddressInAComponentTest {
});
}
/**
* Component receives the address view model before its bindings run.
*/
@Test
void componentReceivesTheAddressViewModelBeforeItsBindingsRun() throws Exception {
onFx(() -> {
@@ -108,6 +126,9 @@ class EditAddressInAComponentTest {
});
}
/**
* Nested component shows the inner street.
*/
@Test
void nestedComponentShowsTheInnerStreet() throws Exception {
onFx(() -> {
@@ -124,6 +145,11 @@ class EditAddressInAComponentTest {
});
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
*/
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
@@ -155,8 +181,16 @@ class EditAddressInAComponentTest {
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -0,0 +1,95 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.component.ActionRow;
import de.neitzel.fx.view.fixture.Host;
import de.neitzel.fx.view.fixture.Visit;
import de.neitzel.injection.ApplicationContext;
import javafx.scene.control.Button;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* A control after the Column fields keeps its own width, and the header uses the Row's spacing.
*/
class KeepAControlAfterTheColumnsTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
FxThread.start();
}
/**
* The remove button stays its own size and the row is wide enough to show it.
*/
@Test
void aControlAfterTheFieldsKeepsItsOwnWidth() throws Exception {
FxThread.onFx(() -> {
Table table = table();
HBox header = header(table);
assertEquals(2, header.getChildren().size());
assertEquals(6, header.getSpacing());
assertEquals(306, header.getPrefWidth());
ActionRow row = row(table);
Region street = (Region) row.getChildren().get(0);
Region city = (Region) row.getChildren().get(1);
Button remove = (Button) row.lookup("#removeButton");
assertEquals(150, street.getPrefWidth());
assertEquals(150, city.getPrefWidth());
assertEquals(Region.USE_COMPUTED_SIZE, remove.getMinWidth());
assertEquals(Region.USE_COMPUTED_SIZE, remove.getPrefWidth());
assertEquals(Region.USE_COMPUTED_SIZE, remove.getMaxWidth());
assertEquals("Remove", remove.getText());
assertEquals(300 + 12 + remove.prefWidth(-1), row.getPrefWidth());
});
}
/**
* Loads the visits table whose row has a button after the fields.
*
* @return the table
*/
private static Table table() {
URL fxml = KeepAControlAfterTheColumnsTest.class.getResource("show-visits-action.fxml");
assertNotNull(fxml);
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(new Visit("Main", "Berlin"))));
LoadedView loaded = ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
return (Table) loaded.getRoot().lookup("#visitsTable");
}
/**
* Returns the header row.
*
* @param table the table
* @return the header
*/
private static HBox header(Table table) {
ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
return (HBox) headerScroll.getContent();
}
/**
* Returns the first row.
*
* @param table the table
* @return the row
*/
private static ActionRow row(Table table) {
ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
return (ActionRow) ((VBox) scroll.getContent()).getChildren().get(0);
}
}
@@ -0,0 +1,155 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.fixture.Host;
import de.neitzel.fx.view.fixture.Visit;
import de.neitzel.injection.ApplicationContext;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.text.Text;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* A Column opens at its starting width. A Column without one takes an equal share.
*/
class OpenAtAGivenWidthTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
FxThread.start();
}
/**
* Named widths are kept, including after a drag and a change of the Table's width.
*/
@Test
void aColumnOpensAtItsWidth() throws Exception {
FxThread.onFx(() -> {
Table table = table("show-visits-widths.fxml");
HBox header = header(table);
Region street = (Region) header.getChildren().get(0);
Region city = (Region) header.getChildren().get(1);
assertEquals(220, street.getPrefWidth());
assertEquals(80, city.getPrefWidth());
assertEquals(220, field(table, 0).getPrefWidth());
assertEquals(80, field(table, 1).getPrefWidth());
drag(street.lookup("." + Table.RESIZE_STYLE), 0, 40);
assertEquals(260, street.getPrefWidth());
assertEquals(80, city.getPrefWidth());
table.setPrefWidth(900);
assertEquals(260, street.getPrefWidth());
assertEquals(80, city.getPrefWidth());
});
}
/**
* A Column with no width takes what the named widths leave of the Table.
*/
@Test
void aColumnWithoutAWidthTakesTheRemainingShare() throws Exception {
FxThread.onFx(() -> {
HBox header = header(table("show-visits-one-width.fxml"));
assertEquals(100, ((Region) header.getChildren().get(0)).getPrefWidth());
assertEquals(200, ((Region) header.getChildren().get(1)).getPrefWidth());
});
}
/**
* A starting width narrower than the header text opens wide enough to show that text.
*/
@Test
void aNarrowWidthOpensAtTheHeaderText() throws Exception {
FxThread.onFx(() -> {
HBox header = header(table("show-visits-narrow.fxml"));
assertEquals(280, ((Region) header.getChildren().get(0)).getPrefWidth());
Text text = new Text("City");
double headerText = Math.ceil(text.getLayoutBounds().getWidth()) + 8;
assertEquals(headerText, ((Region) header.getChildren().get(1)).getPrefWidth());
});
}
/**
* A width that is not positive fails the load.
*/
@Test
void aWidthThatIsNotPositiveFailsToLoad() throws Exception {
FxThread.onFx(() -> assertThrows(IllegalArgumentException.class, () -> table("show-visits-bad-width.fxml")));
}
/**
* Loads a visits table.
*
* @param name the document name
* @return the table
*/
private static Table table(String name) {
URL fxml = OpenAtAGivenWidthTest.class.getResource(name);
assertNotNull(fxml);
Host host = new Host();
host.setVisits(new ArrayList<>(List.of(new Visit("Main", "Berlin"))));
LoadedView loaded = ViewLoader.load(fxml, host, new ApplicationContext("de.neitzel.fx.view.component"));
return (Table) loaded.getRoot().lookup("#visitsTable");
}
/**
* Returns the header row.
*
* @param table the table
* @return the header
*/
private static HBox header(Table table) {
ScrollPane headerScroll = (ScrollPane) table.lookup(".table-header-scroll");
return (HBox) headerScroll.getContent();
}
/**
* Returns one field of the first row.
*
* @param table the table
* @param index the field index
* @return the field
*/
private static Region field(Table table, int index) {
ScrollPane scroll = (ScrollPane) table.lookup("." + Table.SCROLL_STYLE);
javafx.scene.layout.VBox body = (javafx.scene.layout.VBox) scroll.getContent();
return (Region) ((javafx.scene.Parent) body.getChildren().get(0)).getChildrenUnmodifiable().get(index);
}
/**
* Drags a resize handle from one screen x to another.
*
* @param handle the handle
* @param from the start screen x
* @param to the end screen x
*/
private static void drag(javafx.scene.Node handle, double from, double to) {
handle.fireEvent(mouse(MouseEvent.MOUSE_PRESSED, from));
handle.fireEvent(mouse(MouseEvent.MOUSE_DRAGGED, to));
}
/**
* Returns a primary mouse event.
*
* @param type the event type
* @param screen the screen x
* @return the event
*/
private static MouseEvent mouse(javafx.event.EventType<MouseEvent> type, double screen) {
return new MouseEvent(type, screen, 0, screen, 0, MouseButton.PRIMARY, 1,
false, false, false, false, true, false, false, false, false, false, null);
}
}
@@ -31,8 +31,14 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.fail;
/**
* A path steps through a nested view model, and a null step leaves the binding unapplied.
*/
class ReachNestedAttributeTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
@@ -46,6 +52,9 @@ class ReachNestedAttributeTest {
}
}
/**
* Complex attribute holds the view model for the nested address.
*/
@Test
void complexAttributeHoldsTheViewModelForTheNestedAddress() throws Exception {
onFx(() -> {
@@ -61,6 +70,9 @@ class ReachNestedAttributeTest {
});
}
/**
* Both ways text field reads and writes the nested street.
*/
@Test
void bothWaysTextFieldReadsAndWritesTheNestedStreet() throws Exception {
onFx(() -> {
@@ -78,6 +90,9 @@ class ReachNestedAttributeTest {
});
}
/**
* Toward view label follows the nested street and does not write.
*/
@Test
void towardViewLabelFollowsTheNestedStreetAndDoesNotWrite() throws Exception {
onFx(() -> {
@@ -99,6 +114,9 @@ class ReachNestedAttributeTest {
});
}
/**
* Both ways binding on a complex path yields the model instance.
*/
@Test
void bothWaysBindingOnAComplexPathYieldsTheModelInstance() throws Exception {
onFx(() -> {
@@ -117,6 +135,9 @@ class ReachNestedAttributeTest {
});
}
/**
* Filling a missing address stores the view model and writes the model.
*/
@Test
void fillingAMissingAddressStoresTheViewModelAndWritesTheModel() throws Exception {
onFx(() -> {
@@ -137,6 +158,9 @@ class ReachNestedAttributeTest {
});
}
/**
* Missing address leaves the control and the model unchanged.
*/
@Test
void missingAddressLeavesTheControlAndTheModelUnchanged() throws Exception {
onFx(() -> {
@@ -151,6 +175,9 @@ class ReachNestedAttributeTest {
});
}
/**
* Null complex attribute holds null.
*/
@Test
void nullComplexAttributeHoldsNull() throws Exception {
onFx(() -> {
@@ -163,6 +190,9 @@ class ReachNestedAttributeTest {
});
}
/**
* Attributes that reach the same address share its view model.
*/
@Test
void attributesThatReachTheSameAddressShareItsViewModel() throws Exception {
onFx(() -> {
@@ -183,6 +213,9 @@ class ReachNestedAttributeTest {
});
}
/**
* Equal addresses keep separate view models.
*/
@Test
void equalAddressesKeepSeparateViewModels() throws Exception {
onFx(() -> {
@@ -206,6 +239,9 @@ class ReachNestedAttributeTest {
});
}
/**
* A person who is their own friend reuses the same view model.
*/
@Test
void aPersonWhoIsTheirOwnFriendReusesTheSameViewModel() throws Exception {
onFx(() -> {
@@ -219,6 +255,9 @@ class ReachNestedAttributeTest {
});
}
/**
* Two people who point at each other reuse the view models already built.
*/
@Test
void twoPeopleWhoPointAtEachOtherReuseTheViewModelsAlreadyBuilt() throws Exception {
onFx(() -> {
@@ -236,6 +275,9 @@ class ReachNestedAttributeTest {
});
}
/**
* Enums booleans and date values stay simple values.
*/
@Test
void enumsBooleansAndDateValuesStaySimpleValues() throws Exception {
onFx(() -> {
@@ -260,6 +302,9 @@ class ReachNestedAttributeTest {
});
}
/**
* A collection stays the collection rather than a view model.
*/
@Test
void aCollectionStaysTheCollectionRatherThanAViewModel() throws Exception {
onFx(() -> {
@@ -281,6 +326,9 @@ class ReachNestedAttributeTest {
});
}
/**
* Two loads of the same person share the view model.
*/
@Test
void twoLoadsOfTheSamePersonShareTheViewModel() throws Exception {
onFx(() -> {
@@ -299,6 +347,11 @@ class ReachNestedAttributeTest {
});
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
*/
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
@@ -330,8 +383,16 @@ class ReachNestedAttributeTest {
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -28,8 +28,14 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.fail;
/**
* A repeat places one component in a pane for each model instance.
*/
class RepeatAddressComponentsTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
@@ -43,6 +49,9 @@ class RepeatAddressComponentsTest {
}
}
/**
* Addresses are the address instances on the shared list.
*/
@Test
void addressesAreTheAddressInstancesOnTheSharedList() throws Exception {
onFx(() -> {
@@ -67,6 +76,9 @@ class RepeatAddressComponentsTest {
});
}
/**
* Each address appears as its own form in list order.
*/
@Test
void eachAddressAppearsAsItsOwnFormInListOrder() throws Exception {
onFx(() -> {
@@ -96,6 +108,9 @@ class RepeatAddressComponentsTest {
});
}
/**
* Adding and removing an address updates the pane and the entity.
*/
@Test
void addingAndRemovingAnAddressUpdatesThePaneAndTheEntity() throws Exception {
onFx(() -> {
@@ -131,6 +146,9 @@ class RepeatAddressComponentsTest {
});
}
/**
* Selection is a separate attribute and the forms do not store it.
*/
@Test
void selectionIsASeparateAttributeAndTheFormsDoNotStoreIt() throws Exception {
onFx(() -> {
@@ -161,6 +179,9 @@ class RepeatAddressComponentsTest {
});
}
/**
* A second address form in the same document is another component.
*/
@Test
void aSecondAddressFormInTheSameDocumentIsAnotherComponent() throws Exception {
onFx(() -> {
@@ -189,10 +210,21 @@ class RepeatAddressComponentsTest {
});
}
/**
* Returns the street of each address, in list order.
*
* @param addresses the addresses
* @return the streets, in list order
*/
private static List<String> streets(List<Address> addresses) {
return addresses.stream().map(Address::getStreet).toList();
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
*/
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
@@ -224,8 +256,16 @@ class RepeatAddressComponentsTest {
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -20,8 +20,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.fail;
/**
* A list of simple values is one shared list on the control and the model.
*/
class ShowAListOfSimpleValuesTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
@@ -35,6 +41,9 @@ class ShowAListOfSimpleValuesTest {
}
}
/**
* List view and person share the tag list.
*/
@Test
void listViewAndPersonShareTheTagList() throws Exception {
onFx(() -> {
@@ -61,6 +70,11 @@ class ShowAListOfSimpleValuesTest {
});
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
*/
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
@@ -92,8 +106,16 @@ class ShowAListOfSimpleValuesTest {
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -21,8 +21,14 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
* A direct binding shows one attribute and writes it when the direction says so.
*/
class ShowAndEditOneAttributeTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1);
@@ -36,6 +42,9 @@ class ShowAndEditOneAttributeTest {
}
}
/**
* Both ways text field shows the name and typing updates the model.
*/
@Test
void bothWaysTextFieldShowsTheNameAndTypingUpdatesTheModel() throws Exception {
onFx(() -> {
@@ -51,6 +60,9 @@ class ShowAndEditOneAttributeTest {
});
}
/**
* Toward view label shows the name follows the field and does not write the model.
*/
@Test
void towardViewLabelShowsTheNameFollowsTheFieldAndDoesNotWriteTheModel() throws Exception {
onFx(() -> {
@@ -70,6 +82,9 @@ class ShowAndEditOneAttributeTest {
});
}
/**
* Entity setter after load leaves the text control unchanged.
*/
@Test
void entitySetterAfterLoadLeavesTheTextControlUnchanged() throws Exception {
onFx(() -> {
@@ -84,6 +99,9 @@ class ShowAndEditOneAttributeTest {
});
}
/**
* Binding a read only getter fails the load.
*/
@Test
void bindingAReadOnlyGetterFailsTheLoad() throws Exception {
onFx(() -> {
@@ -97,6 +115,9 @@ class ShowAndEditOneAttributeTest {
});
}
/**
* Binding a method declared on object fails the load.
*/
@Test
void bindingAMethodDeclaredOnObjectFailsTheLoad() throws Exception {
onFx(() -> {
@@ -110,6 +131,9 @@ class ShowAndEditOneAttributeTest {
});
}
/**
* Null integer attribute uses an integer property.
*/
@Test
void nullIntegerAttributeUsesAnIntegerProperty() throws Exception {
onFx(() -> {
@@ -122,6 +146,11 @@ class ShowAndEditOneAttributeTest {
});
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
*/
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
@@ -153,8 +182,16 @@ class ShowAndEditOneAttributeTest {
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -29,8 +29,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.fail;
/**
* A message stays on the view model until the developer asks for a dialog.
*/
class ShowMessagesWhenAskedTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll
static void startJavaFx() throws InterruptedException {
Platform.setImplicitExit(false);
@@ -45,6 +51,9 @@ class ShowMessagesWhenAskedTest {
}
}
/**
* Adding a message does not open a popup or fill a status bar.
*/
@Test
void addingAMessageDoesNotOpenAPopupOrFillAStatusBar() throws Exception {
onFx(() -> {
@@ -64,6 +73,9 @@ class ShowMessagesWhenAskedTest {
});
}
/**
* Direct binding shows the latest message text.
*/
@Test
void directBindingShowsTheLatestMessageText() throws Exception {
onFx(() -> {
@@ -82,6 +94,9 @@ class ShowMessagesWhenAskedTest {
});
}
/**
* Opted in helper shows a new message in a popup.
*/
@Test
void optedInHelperShowsANewMessageInAPopup() throws Exception {
onFx(() -> {
@@ -104,6 +119,9 @@ class ShowMessagesWhenAskedTest {
});
}
/**
* Updating a binding failure does not open another popup.
*/
@Test
void updatingABindingFailureDoesNotOpenAnotherPopup() throws Exception {
onFx(() -> {
@@ -140,10 +158,22 @@ class ShowMessagesWhenAskedTest {
});
}
/**
* Returns the windows open before a check.
*
* @return the windows that are open
*/
private static Set<Window> snapshotWindows() {
return new HashSet<>(Window.getWindows());
}
/**
* Returns the window opened during a check whose content is the given text.
*
* @param text the text
* @param alreadyOpen the windows open before the check
* @return the window, or {@code null} when none shows the text
*/
private static Window windowShowing(String text, Set<Window> alreadyOpen) {
for (Window window : Window.getWindows()) {
if (!alreadyOpen.contains(window) && windowShows(window, text)) {
@@ -153,6 +183,13 @@ class ShowMessagesWhenAskedTest {
return null;
}
/**
* Returns whether a window's scene shows the given text.
*
* @param window the window
* @param text the text
* @return {@code true} when the window shows the text
*/
private static boolean windowShows(Window window, String text) {
if (window.getScene() == null || !(window.getScene().getRoot() instanceof Parent root)) {
return false;
@@ -168,10 +205,23 @@ class ShowMessagesWhenAskedTest {
return false;
}
/**
* Returns whether a labeled node shows the given text.
*
* @param node the node
* @param text the text
* @return {@code true} when the node shows the text
*/
private static boolean nodeShows(Node node, String text) {
return node instanceof Labeled labeled && text.equals(labeled.getText());
}
/**
* Returns the windows that were not open in the earlier snapshot.
*
* @param alreadyOpen the windows open before the check
* @return the windows opened since the snapshot
*/
private static List<Window> windowsOpenedSince(Set<Window> alreadyOpen) {
List<Window> opened = new ArrayList<>();
for (Window window : Window.getWindows()) {
@@ -182,6 +232,11 @@ class ShowMessagesWhenAskedTest {
return opened;
}
/**
* Closes the windows opened during a check.
*
* @param alreadyOpen the windows open before the check
*/
private static void closeWindowsOpenedSince(Set<Window> alreadyOpen) {
List<Window> open = new ArrayList<>(Window.getWindows());
for (Window window : open) {
@@ -191,6 +246,12 @@ class ShowMessagesWhenAskedTest {
}
}
/**
* Fails when the given text is visible in the scene or in a dialog.
*
* @param root the loaded scene
* @param text the text
*/
private static void assertMessageIsNotShown(Parent root, String text) {
for (Node node : root.lookupAll("*")) {
if (node instanceof Labeled labeled) {
@@ -202,6 +263,11 @@ class ShowMessagesWhenAskedTest {
}
}
/**
* Runs one check on the JavaFX application thread.
*
* @param action the check
*/
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
@@ -233,8 +299,16 @@ class ShowMessagesWhenAskedTest {
}
}
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface
private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception;
}
}
@@ -5,11 +5,14 @@ import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
/**
* Replacement for the library ViewModel. It exposes a badge property by name and does not
* Replacement for the library viewmodel. it exposes a badge property by name and does not
* extend {@link de.neitzel.fx.view.ViewModel}.
*/
public class BadgeViewModel {
/**
* The badge text this view model adds beside the model attributes.
*/
private final StringProperty badge = new SimpleStringProperty();
/**
@@ -23,7 +26,7 @@ public class BadgeViewModel {
}
/**
* Returns the badge property a Binding element addresses by name.
* Returns the badge property a binding element addresses by name.
*
* @return the badge property
*/
@@ -13,10 +13,13 @@ import javafx.scene.layout.VBox;
@ViewModelClass(StreetTitleViewModel.class)
public class CaptionAddressForm extends VBox {
/**
* The model this component edits. A parent view or a repeat sets it once.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* Returns the model property a parent View sets with a Direct binding.
* Returns the model property a parent view sets with a direct binding.
*
* @return the model property
*/
@@ -9,6 +9,9 @@ import jakarta.inject.Singleton;
@Singleton
public class DeskController {
/**
* The service the greeting method reads.
*/
private final DeskGreeting greeting;
/**
@@ -3,7 +3,7 @@ package de.neitzel.fx.view.chosen;
import jakarta.inject.Singleton;
/**
* Service whose text a Controller must show, so a test can see that injection ran.
* Service whose text a controller must show, so a test can see that injection ran.
*/
@Singleton
public class DeskGreeting {
@@ -10,12 +10,15 @@ import jakarta.inject.Singleton;
@Singleton
public class HandedNameController implements HasViewModel {
/**
* The name handed to this controller before the bindings run.
*/
private String handed;
/**
* Records a label value that is available only from the handed ViewModel.
*
* @param viewModel the ViewModel for this View
* @param viewModel the view model
*/
@Override
public void setViewModel(ViewModel viewModel) {
@@ -10,6 +10,9 @@ import javafx.beans.property.StringProperty;
*/
public class StreetTitleViewModel extends ViewModel {
/**
* The caption this view model adds for the street.
*/
private final StringProperty caption = new SimpleStringProperty();
/**
@@ -24,7 +27,7 @@ public class StreetTitleViewModel extends ViewModel {
}
/**
* Returns the caption property a Binding element addresses by name.
* Returns the caption property a binding element addresses by name.
*
* @return the caption property
*/
@@ -4,7 +4,7 @@ import de.neitzel.fx.view.ViewModelClass;
import jakarta.inject.Singleton;
/**
* Controller that names {@link TitledViewModel} for its View.
* Controller that names {@link titledviewmodel} for its view.
*/
@Singleton
@ViewModelClass(TitledViewModel.class)
@@ -10,6 +10,9 @@ import javafx.beans.property.StringProperty;
*/
public class TitledViewModel extends ViewModel {
/**
* The title this view model adds beside the model attributes.
*/
private final StringProperty title = new SimpleStringProperty();
/**
@@ -24,7 +27,7 @@ public class TitledViewModel extends ViewModel {
}
/**
* Returns the title property a Binding element addresses by name.
* Returns the title property a binding element addresses by name.
*
* @return the title property
*/
@@ -0,0 +1,53 @@
package de.neitzel.fx.view.component;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.layout.HBox;
/**
* A Row with street and city fields and a control after them.
*/
@Singleton
public class ActionRow extends HBox {
/**
* The visit this row shows.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* Creates a row.
*/
@Inject
public ActionRow() {
}
/**
* Returns the model property.
*
* @return the model property
*/
public ObjectProperty<Object> modelProperty() {
return model;
}
/**
* Returns the visit this row shows.
*
* @return the visit, or {@code null}
*/
public Object getModel() {
return model.get();
}
/**
* Sets the visit this row shows.
*
* @param model the visit
*/
public void setModel(Object model) {
this.model.set(model);
}
}
@@ -14,21 +14,39 @@ import javafx.scene.layout.VBox;
/**
* Component that shows one address.
* <p>
* The container injects the caption service. The street label shows that service's prefix.
* The container injects the caption service. the street label shows that service's prefix.
*/
@Singleton
public class AddressForm extends VBox implements HasViewModel {
/**
* The service that supplies the street caption.
*/
private final StreetCaption captions;
/**
* The model this component edits. A parent view or a repeat sets it once.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* The label filled by the component's own binding.
*/
@FXML
private Label streetLabel;
/**
* The street field filled by the component's own binding.
*/
@FXML
private TextField streetField;
/**
* The view model handed over before the component's bindings run.
*/
private ViewModel receivedViewModel;
/**
* The street text visible when the view model is handed over.
*/
private String streetTextAtHandOff;
/**
@@ -50,7 +68,7 @@ public class AddressForm extends VBox implements HasViewModel {
}
/**
* Returns the model property a parent View sets with a Direct binding.
* Returns the model property a parent view sets with a direct binding.
*
* @return the model property
*/
@@ -79,7 +97,7 @@ public class AddressForm extends VBox implements HasViewModel {
/**
* Records the ViewModel handed to this component, and the street text at that moment.
*
* @param viewModel the ViewModel selected for the address
* @param viewModel the view model
*/
@Override
public void setViewModel(ViewModel viewModel) {
@@ -7,6 +7,9 @@ import de.neitzel.fx.view.fixture.Address;
*/
public class Envelope {
/**
* The address this model holds.
*/
private Address address;
/**
@@ -11,10 +11,13 @@ import javafx.scene.layout.VBox;
@Singleton
public class EnvelopeForm extends VBox {
/**
* The model this component edits. A parent view or a repeat sets it once.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* Returns the model property a parent View sets with a Direct binding.
* Returns the model property a parent view sets with a direct binding.
*
* @return the model property
*/
@@ -5,6 +5,9 @@ package de.neitzel.fx.view.component;
*/
public class Letter {
/**
* The envelope this letter holds.
*/
private Envelope envelope;
/**
@@ -5,11 +5,14 @@ import javafx.fxml.FXML;
import javafx.scene.control.TextField;
/**
* Controller that derives a label from the name field and does not write the Model.
* Controller that derives a label from the name field and does not write the model.
*/
@Singleton
public class WorkingAgeController {
/**
* The name field a computed binding reads.
*/
@FXML
private TextField nameField;
@@ -10,6 +10,9 @@ import java.util.Objects;
*/
public class Address {
/**
* The street.
*/
private String street;
/**
@@ -39,6 +42,12 @@ public class Address {
this.street = street;
}
/**
* Compares addresses by street.
*
* @param other the other address
* @return {@code true} when the streets are equal
*/
@Override
public boolean equals(Object other) {
if (other instanceof Address address) {
@@ -47,6 +56,11 @@ public class Address {
return false;
}
/**
* Returns a hash code for the street.
*
* @return the hash code
*/
@Override
public int hashCode() {
return Objects.hashCode(street);
@@ -5,6 +5,9 @@ package de.neitzel.fx.view.fixture;
*/
public class Household {
/**
* The person in this household.
*/
private Person person;
/**
@@ -9,6 +9,9 @@ import javafx.scene.layout.Pane;
*/
public class ObjectHolder extends Pane {
/**
* The value a direct binding writes.
*/
private final ObjectProperty<Object> value = new SimpleObjectProperty<>(this, "value");
/**
@@ -10,19 +10,61 @@ import java.util.List;
*/
public class Person {
/**
* The name.
*/
private String name;
/**
* The age in years, or {@code null} when it has not been set.
*/
private Integer age;
/**
* Whether the person is active.
*/
private boolean active;
/**
* The address this model holds.
*/
private Address address;
/**
* The home address.
*/
private Address home;
/**
* The work address.
*/
private Address work;
/**
* Another person, used to follow a cycle.
*/
private Person friend;
/**
* The person's role.
*/
private Role role;
/**
* The birth date.
*/
private LocalDate born;
/**
* The last update.
*/
private Date updated;
/**
* The hire date.
*/
private Calendar hired;
/**
* The tags. after a view loads, this is the list the control watches.
*/
private List<String> tags;
/**
* The addresses. after a view loads, this is the list a repeat watches.
*/
private List<Address> addresses;
/**
* The selected address, or {@code null} when none is selected.
*/
private Address selectedAddress;
/**
@@ -62,7 +104,7 @@ public class Person {
}
/**
* Returns the age. The value may be null.
* Returns the age. the value may be null.
*
* @return the age
*/
@@ -242,7 +284,7 @@ public class Person {
}
/**
* Returns the tags. The value may be null.
* Returns the tags. the value may be null.
*
* @return the tags
*/
@@ -260,7 +302,7 @@ public class Person {
}
/**
* Returns the addresses. The value may be null.
* Returns the addresses. the value may be null.
*
* @return the addresses
*/
@@ -278,7 +320,7 @@ public class Person {
}
/**
* Returns the selected address. Null means nothing is selected.
* Returns the selected address. null means nothing is selected.
*
* @return the selected address, or {@code null}
*/
@@ -10,6 +10,9 @@ import jakarta.inject.Singleton;
@Singleton
public class PrefixedAgeConverter implements Converter {
/**
* The unit written in front of an age.
*/
private final AgeUnit ageUnit;
/**
@@ -4,6 +4,13 @@ package de.neitzel.fx.view.fixture;
* A simple enum Attribute used by view-loading tests.
*/
public enum Role {
/**
* An ordinary member.
*/
MEMBER,
/**
* An administrator.
*/
ADMIN
}
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.HBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<fx:root type="javafx.scene.layout.HBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" spacing="6">
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="street" direction="both"/>
<DirectBinding control="cityField" property="text" path="city" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="streetField" id="streetField"/>
<TextField fx:id="cityField" id="cityField"/>
<Button fx:id="removeButton" id="removeButton" text="Remove"/>
</fx:root>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<Table fx:id="visitsTable" id="visitsTable" collection="visits"
row="de.neitzel.fx.view.component.ActionRow" prefWidth="300">
<Column header="Street" attribute="street"/>
<Column header="City" attribute="city"/>
</Table>
</VBox>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<Table fx:id="visitsTable" id="visitsTable" collection="visits"
row="de.neitzel.fx.view.component.VisitRow" prefWidth="300">
<Column header="Street" attribute="street" width="100"/>
<Column header="City" attribute="city" width="0"/>
</Table>
</VBox>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<Table fx:id="visitsTable" id="visitsTable" collection="visits"
row="de.neitzel.fx.view.component.VisitRow" prefWidth="300">
<Column header="Street" attribute="street" width="280"/>
<Column header="City" attribute="city" width="1"/>
</Table>
</VBox>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<Table fx:id="visitsTable" id="visitsTable" collection="visits"
row="de.neitzel.fx.view.component.VisitRow" prefWidth="300">
<Column header="Street" attribute="street" width="100"/>
<Column header="City" attribute="city"/>
</Table>
</VBox>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<Table fx:id="visitsTable" id="visitsTable" collection="visits"
row="de.neitzel.fx.view.component.VisitRow" prefWidth="300">
<Column header="Street" attribute="street" width="220"/>
<Column header="City" attribute="city" width="80"/>
</Table>
</VBox>