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
+6 -2
View File
@@ -43,11 +43,15 @@ Some encryption classes that use format-preserving-encryption.
### fx ### fx
JavaFX library that binds an FXML View to an ordinary Java Model. See [fx/README.md](fx/README.md). JavaFX library that binds an FXML view to an ordinary Java model. See [fx/README.md](fx/README.md).
### injection
Package scanner and type-based container used by `fx`. See [injection/README.md](injection/README.md).
### fx-example ### fx-example
Person desk that shows how to use `fx`. Run `mvn -pl fx-example javafx:run`. Person desk that shows how to use `fx` and `injection`. Run `mvn -pl fx-example -am javafx:run`. See [fx-example/README.md](fx-example/README.md).
### gson ### gson
@@ -242,8 +242,16 @@ public class Query<T> {
*/ */
private Stream<T> streamFromResultSet(final ResultSet rs, final Function<ResultSet, T> factory) { private Stream<T> streamFromResultSet(final ResultSet rs, final Function<ResultSet, T> factory) {
Iterator<T> iterator = new Iterator<>() { Iterator<T> iterator = new Iterator<>() {
/**
* Whether this iterator has already looked ahead in the {@link ResultSet}.
* A later hasNext call reuses that look-ahead until next consumes the row.
*/
private boolean hasNextChecked = false; private boolean hasNextChecked = false;
/**
* Whether the look-ahead found another row in the {@link ResultSet}.
* The value is meaningful only after the look-ahead has run.
*/
private boolean hasNext; private boolean hasNext;
/** /**
@@ -15,6 +15,10 @@ import java.security.SecureRandom;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public class FF1Encryption { public class FF1Encryption {
/**
* Random source used when creating a new tweak.
* {@link #createNewTweak(int)} fills the tweak from this generator.
*/
private static final SecureRandom RANDOM = new SecureRandom(); private static final SecureRandom RANDOM = new SecureRandom();
/** /**
+5 -3
View File
@@ -1,11 +1,13 @@
# fx-example # fx-example
A person desk that uses the [`fx`](../fx/README.md) library: a Direct binding, a Converter, a Computed binding, a nested Component, a Repeat, a list of simple values, and Messages. A person desk that uses [`fx`](../fx/README.md) and [`injection`](../injection/README.md). The guides use this module as the worked example.
The desk shows a direct binding in each direction, a converter, a computed binding, a view model subclass, a component with a nested note, a repeat, a table with a starting width and an open column, a date column, a list of simple values, and messages. The container side shows `@Config`, `@Singleton`, `@Named`, and injection by constructor, field, and setter.
From the NeitzelLib root, with Java 21: From the NeitzelLib root, with Java 21:
```bash ```bash
mvn -pl fx-example javafx:run mvn -pl fx-example -am javafx:run
``` ```
The launch class is `de.neitzel.fx.example.Main`. The Model is an ordinary `Person`. The View is `Desk.fxml`. The launch class is `de.neitzel.fx.example.Main`. The model is an ordinary `Person`. The view is `Desk.fxml`. `DeskLoadsTest` loads that same desk.
+35
View File
@@ -52,6 +52,41 @@
<mainClass>${main.class}</mainClass> <mainClass>${main.class}</mainClass>
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<classpathPrefix>libs/</classpathPrefix>
<mainClass>${main.class}</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>${maven.dependency.plugin}</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/libs</outputDirectory>
<includeScope>runtime</includeScope>
<useBaseVersion>true</useBaseVersion>
<overWriteReleases>false</overWriteReleases>
<overWriteSnapshots>true</overWriteSnapshots>
<overWriteIfNewer>true</overWriteIfNewer>
</configuration>
</execution>
</executions>
</plugin>
<plugin> <plugin>
<groupId>com.github.spotbugs</groupId> <groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId> <artifactId>spotbugs-maven-plugin</artifactId>
@@ -1,22 +1,31 @@
package de.neitzel.fx.example; package de.neitzel.fx.example;
import de.neitzel.fx.example.model.Address; import de.neitzel.fx.example.model.Address;
import de.neitzel.fx.example.model.Note;
import de.neitzel.fx.example.model.Person; import de.neitzel.fx.example.model.Person;
import de.neitzel.fx.example.model.Role;
import de.neitzel.fx.example.view.DeskController; import de.neitzel.fx.example.view.DeskController;
import de.neitzel.fx.view.LoadedView; import de.neitzel.fx.view.LoadedView;
import de.neitzel.fx.view.MessagePopup; import de.neitzel.fx.view.MessagePopup;
import de.neitzel.fx.view.ViewLoader; import de.neitzel.fx.view.ViewLoader;
import de.neitzel.injection.ApplicationContext; import de.neitzel.injection.ApplicationContext;
import de.neitzel.injection.annotation.Config;
import javafx.application.Application; import javafx.application.Application;
import javafx.scene.Scene; import javafx.scene.Scene;
import javafx.stage.Stage; import javafx.stage.Stage;
import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
* Opens the person desk for one ordinary {@link Person}. * Opens the person desk for one ordinary {@link Person}.
* <p>
* {@link Config} names the package the container scans. The same package would be used without
* the annotation, because this class lives there. The attribute is set so the annotation is what
* chooses the package.
*/ */
@Config(basePackage = "de.neitzel.fx.example")
public class DeskApp extends Application { public class DeskApp extends Application {
/** /**
@@ -28,16 +37,24 @@ public class DeskApp extends Application {
/** /**
* Builds the person the desk edits. * Builds the person the desk edits.
* *
* @return a person with a home address, one visit, and two tags * @return a person with a home address, three visits, and two tags
*/ */
public static Person sample() { public static Person sample() {
Address home = new Address("Sample Street 1", "Sample City"); Address home = new Address("Sample Street 1", "Sample City");
home.setNote(new Note("Kept at home"));
Address visit = new Address("Work Road 2", "Office City"); Address visit = new Address("Work Road 2", "Office City");
visit.setVisitedOn(LocalDate.of(2020, 1, 2));
Address berlin = new Address("Sample Lane", "Berlin");
berlin.setVisitedOn(LocalDate.of(2020, 3, 4));
Address bonn = new Address("Oak Street", "Bonn");
bonn.setVisitedOn(LocalDate.of(2019, 12, 1));
Person person = new Person(); Person person = new Person();
person.setName("Ada Lovelace"); person.setName("Ada Lovelace");
person.setAge(36); person.setAge(36);
person.setActive(true);
person.setRole(Role.AUTHOR);
person.setHome(home); person.setHome(home);
person.setVisits(new ArrayList<>(List.of(visit))); person.setVisits(new ArrayList<>(List.of(visit, berlin, bonn)));
person.setSelectedVisit(visit); person.setSelectedVisit(visit);
person.setTags(new ArrayList<>(List.of("dns", "math"))); person.setTags(new ArrayList<>(List.of("dns", "math")));
return person; return person;
@@ -9,6 +9,9 @@ import javafx.application.Application;
*/ */
public final class Main { public final class Main {
/**
* Prevents instantiation. The class is only a launch entry point.
*/
private Main() { private Main() {
} }
@@ -1,13 +1,32 @@
package de.neitzel.fx.example.model; package de.neitzel.fx.example.model;
import java.time.LocalDate;
/** /**
* An ordinary address the desk edits. * An ordinary address the desk edits.
*/ */
public class Address { public class Address {
/**
* The street.
*/
private String street = ""; private String street = "";
/**
* The city.
*/
private String city = ""; private String city = "";
/**
* The day of the visit, or {@code null} when the address is not a dated visit.
*/
private LocalDate visitedOn;
/**
* The note attached to this address, or {@code null} when there is none.
*/
private Note note;
/** /**
* Creates an empty address. * Creates an empty address.
*/ */
@@ -61,6 +80,42 @@ public class Address {
this.city = city; this.city = city;
} }
/**
* Returns the day of the visit.
*
* @return the date, or {@code null} when none is set
*/
public LocalDate getVisitedOn() {
return visitedOn;
}
/**
* Sets the day of the visit.
*
* @param visitedOn the date, or {@code null} to clear it
*/
public void setVisitedOn(LocalDate visitedOn) {
this.visitedOn = visitedOn;
}
/**
* Returns the note attached to this address.
*
* @return the note, or {@code null} when none is set
*/
public Note getNote() {
return note;
}
/**
* Sets the note attached to this address.
*
* @param note the note, or {@code null} when there is none
*/
public void setNote(Note note) {
this.note = note;
}
/** /**
* Returns the street and city, for a combo box row. * Returns the street and city, for a combo box row.
* *
@@ -0,0 +1,48 @@
package de.neitzel.fx.example.model;
/**
* A short note attached to an address.
* <p>
* The class is a complex attribute of {@link Address}: it has a read-write property of its own.
* The home address form nests a component that edits this note.
*/
public class Note {
/**
* The note text.
*/
private String text = "";
/**
* Creates an empty note.
*/
public Note() {
}
/**
* Creates a note with the given text.
*
* @param text the note text
*/
public Note(String text) {
this.text = text;
}
/**
* Returns the note text.
*
* @return the text
*/
public String getText() {
return text;
}
/**
* Sets the note text.
*
* @param text the text
*/
public void setText(String text) {
this.text = text;
}
}
@@ -10,13 +10,46 @@ import java.util.List;
*/ */
public class Person { public class Person {
/**
* The person's name.
*/
private String name = ""; private String name = "";
/**
* The person's age in years, or {@code null} when it has not been set.
*/
private Integer age; private Integer age;
/**
* The home address.
*/
private Address home; private Address home;
/**
* The visit addresses. After the View loads, this is the list the visit table and Repeat watch.
*/
private List<Address> visits = new ArrayList<>(); private List<Address> visits = new ArrayList<>();
/**
* The visit selected in the table and the combo box, or {@code null} when none is selected.
*/
private Address selectedVisit; private Address selectedVisit;
/**
* The tags. After the View loads, this is the list the list view watches.
*/
private List<String> tags = new ArrayList<>(); private List<String> tags = new ArrayList<>();
/**
* Whether the person is active. A primitive boolean cannot be null.
*/
private boolean active;
/**
* The person's role, or {@code null} when none is set.
*/
private Role role;
/** /**
* Creates an empty person. * Creates an empty person.
*/ */
@@ -98,7 +131,7 @@ public class Person {
} }
/** /**
* Returns the visit chosen in the combo box. * Returns the visit chosen in the table and the combo box.
* *
* @return the selected visit, or {@code null} when none is selected * @return the selected visit, or {@code null} when none is selected
*/ */
@@ -107,7 +140,7 @@ public class Person {
} }
/** /**
* Sets the visit chosen in the combo box. * Sets the visit chosen in the table and the combo box.
* *
* @param selectedVisit the selected visit * @param selectedVisit the selected visit
*/ */
@@ -134,4 +167,40 @@ public class Person {
public void setTags(List<String> tags) { public void setTags(List<String> tags) {
this.tags = tags; this.tags = tags;
} }
/**
* Returns whether the person is active.
*
* @return {@code true} when the person is active
*/
public boolean isActive() {
return active;
}
/**
* Sets whether the person is active.
*
* @param active {@code true} when the person is active
*/
public void setActive(boolean active) {
this.active = active;
}
/**
* Returns the role.
*
* @return the role, or {@code null} when none is set
*/
public Role getRole() {
return role;
}
/**
* Sets the role.
*
* @param role the role
*/
public void setRole(Role role) {
this.role = role;
}
} }
@@ -0,0 +1,19 @@
package de.neitzel.fx.example.model;
/**
* The role a person has on the desk.
* <p>
* An enum is a simple attribute. The desk binds it to a choice box.
*/
public enum Role {
/**
* A person who reads.
*/
READER,
/**
* A person who writes.
*/
AUTHOR
}
@@ -1,21 +1,47 @@
package de.neitzel.fx.example.view; package de.neitzel.fx.example.view;
import de.neitzel.fx.view.HasViewModel;
import de.neitzel.fx.view.ViewModel;
import jakarta.inject.Inject;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleObjectProperty;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
/** /**
* Component that edits one address. * Component that edits the home address, including its nested note.
* <p> * <p>
* The class is the root and the Controller of {@code AddressForm.fxml}. A parent View sets * The class is the root and the controller of {@code AddressForm.fxml}. The desk sets
* {@link #modelProperty()} from a Direct binding. A repeated row gets its own instance. * {@link #modelProperty()} once. {@link #setHeading} is setter injection. {@link #setViewModel}
* runs after that model is set and before this form's own bindings.
*/ */
@Singleton @Singleton
public class AddressForm extends VBox { public class AddressForm extends VBox implements HasViewModel {
/**
* The address this form edits. The desk sets it once.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model"); private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* The heading shown above the fields. The container sets it through {@link #setHeading}.
*/
private FormHeading heading;
/**
* The heading label. {@link #initialize()} writes the injected heading onto it.
*/
@FXML
private Label headingLabel;
/**
* The label filled from the address before the field bindings run.
*/
@FXML
private Label openedAsLabel;
/** /**
* Creates an empty form. The container calls this constructor. * Creates an empty form. The container calls this constructor.
*/ */
@@ -23,7 +49,37 @@ public class AddressForm extends VBox {
} }
/** /**
* Returns the model property a parent View sets. * Receives the heading. The container calls this setter after construction.
*
* @param heading the heading
*/
@Inject
public void setHeading(FormHeading heading) {
this.heading = heading;
}
/**
* Writes the injected heading onto the label.
* <p>
* FXML calls this during load, before the model is set.
*/
@FXML
public void initialize() {
headingLabel.setText(heading.text());
}
/**
* Records the city as it stands when the model arrives, before the field bindings run.
*
* @param viewModel the address view model
*/
@Override
public void setViewModel(ViewModel viewModel) {
openedAsLabel.setText("Opened as " + viewModel.property("city").getValue());
}
/**
* Returns the model property the desk sets.
* *
* @return the model property * @return the model property
*/ */
@@ -10,6 +10,9 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class AgeConverter implements Converter { public class AgeConverter implements Converter {
/**
* The text written in front of the age.
*/
private final AgeSuffix suffix; private final AgeSuffix suffix;
/** /**
@@ -1,11 +1,14 @@
package de.neitzel.fx.example.view; package de.neitzel.fx.example.view;
import jakarta.inject.Singleton; import jakarta.inject.Named;
/** /**
* The suffix an {@link AgeConverter} writes in front of an age. * The suffix an {@link AgeConverter} writes in front of an age.
* <p>
* {@link Named} marks the class for scanning the same way {@link jakarta.inject.Singleton} does.
* The container does not read the annotation value, and the instance is still cached.
*/ */
@Singleton @Named
public class AgeSuffix { public class AgeSuffix {
/** /**
@@ -4,28 +4,40 @@ import de.neitzel.fx.example.model.Address;
import de.neitzel.fx.view.HasViewModel; import de.neitzel.fx.view.HasViewModel;
import de.neitzel.fx.view.Message; import de.neitzel.fx.view.Message;
import de.neitzel.fx.view.ViewModel; import de.neitzel.fx.view.ViewModel;
import de.neitzel.fx.view.ViewModelClass;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.scene.control.TextField; import javafx.scene.control.TextField;
import java.time.LocalDate;
import java.util.List; import java.util.List;
/** /**
* Controller for the person desk. * Controller for the person desk.
* <p> * <p>
* The container creates this class and injects nothing else. {@link #setViewModel} receives the * The container creates this class. {@link #setViewModel} receives the view model before the
* ViewModel before the binding elements run, so {@link #greeting()} can read the name field on * binding elements run, so {@link #greeting()} can read the name field and the age on its first
* its first call. * call. {@link PersonViewModel} is the view model class for this controller.
*/ */
@Singleton @Singleton
@ViewModelClass(PersonViewModel.class)
public class DeskController implements HasViewModel { public class DeskController implements HasViewModel {
/**
* The name field. {@link #greeting()} reads it.
*/
@FXML @FXML
private TextField nameField; private TextField nameField;
/**
* The tag field cleared after {@link #addTag()}.
*/
@FXML @FXML
private TextField tagField; private TextField tagField;
/**
* The person view model, set before the bindings run.
*/
private ViewModel viewModel; private ViewModel viewModel;
/** /**
@@ -35,9 +47,9 @@ public class DeskController implements HasViewModel {
} }
/** /**
* Receives the ViewModel for the person on the desk. * Receives the view model for the person on the desk.
* *
* @param viewModel the ViewModel * @param viewModel the view model
*/ */
@Override @Override
public void setViewModel(ViewModel viewModel) { public void setViewModel(ViewModel viewModel) {
@@ -47,16 +59,18 @@ public class DeskController implements HasViewModel {
/** /**
* Returns the greeting written to the label. * Returns the greeting written to the label.
* <p> * <p>
* The method reads the name field. It does not write the Model. * The method reads the name field and the age on the view model. It does not write the model.
* A change of either one calls it again.
* *
* @return the greeting * @return the greeting
*/ */
public String greeting() { public String greeting() {
String name = nameField.getText(); String name = nameField.getText();
Object age = viewModel.property("age").getValue();
if (name == null || name.isBlank()) { if (name == null || name.isBlank()) {
return "Hello"; return "Hello (" + age + ")";
} }
return "Hello, " + name.trim(); return "Hello, " + name.trim() + " (" + age + ")";
} }
/** /**
@@ -64,7 +78,21 @@ public class DeskController implements HasViewModel {
*/ */
@FXML @FXML
public void addVisit() { public void addVisit() {
visits().add(new Address("New street", "New city")); Address created = new Address("New street", "New city");
created.setVisitedOn(LocalDate.now());
visits().add(created);
}
/**
* Removes a visit from the list the table and Repeat are watching.
*
* @param visit the visit
*/
public void removeVisit(Address visit) {
if (visit == null) {
return;
}
visits().remove(visit);
} }
/** /**
@@ -81,22 +109,63 @@ public class DeskController implements HasViewModel {
} }
/** /**
* Adds an info Message that quotes the name already stored on the Model. * Adds an info message that quotes the name already stored on the model.
*/ */
@FXML @FXML
public void leaveNote() { public void leaveNote() {
Object name = viewModel.property("name").getValue(); leave(Message.Severity.INFO, "The model already has the name " + name() + ".");
viewModel.addMessage(new Message(
Message.Severity.INFO,
"The model already has the name " + name + ".",
"desk"));
} }
/**
* Adds a warning. The notice label shows it, and it is not written to the log.
*/
@FXML
public void leaveWarning() {
leave(Message.Severity.WARN, "Check the age before leaving the desk.");
}
/**
* Adds a fatal notice. The notice label shows it, and it is written to the log.
*/
@FXML
public void leaveFatal() {
leave(Message.Severity.FATAL, "The desk cannot store this person.");
}
/**
* Adds a notice whose source is this desk.
*
* @param severity how serious the notice is
* @param text the notice text
*/
private void leave(Message.Severity severity, String text) {
viewModel.addMessage(new Message(severity, text, "desk"));
}
/**
* Returns the name currently held by the view model.
*
* @return the name
*/
private Object name() {
return viewModel.property("name").getValue();
}
/**
* Returns the visit list the view is watching.
*
* @return the visits
*/
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private List<Address> visits() { private List<Address> visits() {
return (List<Address>) viewModel.property("visits").getValue(); return (List<Address>) viewModel.property("visits").getValue();
} }
/**
* Returns the tag list the view is watching.
*
* @return the tags
*/
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private List<String> tags() { private List<String> tags() {
return (List<String>) viewModel.property("tags").getValue(); return (List<String>) viewModel.property("tags").getValue();
@@ -0,0 +1,25 @@
package de.neitzel.fx.example.view;
import jakarta.inject.Singleton;
/**
* The heading an address form shows above its fields.
*/
@Singleton
public class FormHeading {
/**
* Creates the heading used by the desk.
*/
public FormHeading() {
}
/**
* Returns the heading text.
*
* @return the heading
*/
public String text() {
return "Address";
}
}
@@ -0,0 +1,54 @@
package de.neitzel.fx.example.view;
import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.layout.VBox;
/**
* Component that edits one {@link de.neitzel.fx.example.model.Note}.
* <p>
* The class is the root and the controller of {@code NoteForm.fxml}. The home address form
* nests this component and sets {@link #modelProperty()} once.
*/
@Singleton
public class NoteForm extends VBox {
/**
* The note this form edits. The parent component sets it once.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* Creates an empty form. The container calls this constructor.
*/
public NoteForm() {
}
/**
* Returns the model property the parent sets.
*
* @return the model property
*/
public ObjectProperty<Object> modelProperty() {
return model;
}
/**
* Returns the note this form edits.
*
* @return the note, or {@code null} when none is set
*/
public Object getModel() {
return model.get();
}
/**
* Sets the note this form edits.
*
* @param model the note
*/
public void setModel(Object model) {
this.model.set(model);
}
}
@@ -0,0 +1,40 @@
package de.neitzel.fx.example.view;
import de.neitzel.fx.example.model.Person;
import de.neitzel.fx.view.ViewModel;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
/**
* ViewModel for a {@link Person} that adds a title beside the person's attributes.
* <p>
* The library constructs this class. The container does not. {@link DeskController} names it
* with {@link de.neitzel.fx.view.ViewModelClass}.
*/
public class PersonViewModel extends ViewModel {
/**
* The title this view model adds. A binding addresses it as {@code title}.
*/
private final StringProperty title = new SimpleStringProperty(this, "title");
/**
* Builds the view model and keeps the title aligned with the person's name.
*
* @param model the person
*/
public PersonViewModel(Object model) {
super(model);
title.set("titled-" + ((Person) model).getName());
property("name").addListener((observable, oldValue, newValue) -> title.set("titled-" + newValue));
}
/**
* Returns the title property a binding element addresses by name.
*
* @return the title property
*/
public StringProperty titleProperty() {
return title;
}
}
@@ -0,0 +1,56 @@
package de.neitzel.fx.example.view;
import de.neitzel.fx.view.Converter;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
/**
* Keeps a street that is not blank, and reports an empty street through the injected policy.
* <p>
* The container fills {@link #policy} by field injection after the no-arg constructor runs.
*/
@Singleton
public class StreetConverter implements Converter {
/**
* The notice text used when the street is empty. The container sets this field.
*/
@Inject
private StreetPolicy policy;
/**
* Creates a converter. The container calls this constructor, then injects {@link #policy}.
*/
public StreetConverter() {
}
/**
* Returns the street unchanged.
*
* @param attributeValue the street
* @return the text shown in the field
*/
@Override
public Object toControlValue(Object attributeValue) {
if (attributeValue == null) {
return "";
}
return attributeValue.toString();
}
/**
* Returns the street with surrounding whitespace removed.
*
* @param controlValue the field text
* @return the street written to the address
* @throws IllegalArgumentException when the text is blank
*/
@Override
public Object toAttributeValue(Object controlValue) {
String text = controlValue == null ? "" : controlValue.toString().trim();
if (text.isEmpty()) {
throw new IllegalArgumentException(policy.message());
}
return text;
}
}
@@ -0,0 +1,25 @@
package de.neitzel.fx.example.view;
import jakarta.inject.Singleton;
/**
* The rule a {@link StreetConverter} applies when a street is empty.
*/
@Singleton
public class StreetPolicy {
/**
* Creates the policy used by the desk.
*/
public StreetPolicy() {
}
/**
* Returns the notice text for an empty street.
*
* @return the notice text
*/
public String message() {
return "Street is required";
}
}
@@ -0,0 +1,56 @@
package de.neitzel.fx.example.view;
import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.layout.VBox;
/**
* Component that edits one visit address in the repeated list.
* <p>
* The class is the root and the controller of {@code VisitForm.fxml}. Repeat creates a new
* instance for each visit. The home address uses {@link AddressForm}, which nests a note.
* This form does not, so a visit added after the desk has loaded does not reuse the note
* component already showing on the home address.
*/
@Singleton
public class VisitForm extends VBox {
/**
* The visit this form edits. Repeat sets it once.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* Creates an empty form. The container calls this constructor.
*/
public VisitForm() {
}
/**
* Returns the model property Repeat sets.
*
* @return the model property
*/
public ObjectProperty<Object> modelProperty() {
return model;
}
/**
* Returns the visit this form edits.
*
* @return the visit, or {@code null} when none is set
*/
public Object getModel() {
return model.get();
}
/**
* Sets the visit this form edits.
*
* @param model the visit
*/
public void setModel(Object model) {
this.model.set(model);
}
}
@@ -0,0 +1,77 @@
package de.neitzel.fx.example.view;
import de.neitzel.fx.example.model.Address;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.fxml.FXML;
import javafx.scene.layout.HBox;
/**
* Row that edits one visit in the visit table.
* <p>
* The class is the root and the Controller of {@code VisitRow.fxml}. The table sets
* {@link #modelProperty()} once, and this row binds its own fields. Those fields are in column
* order: street, city, then the visit date. Remove sits after them and is not a column.
*/
@Singleton
public class VisitRow extends HBox {
/**
* The desk controller that removes a visit from the person.
*/
private final DeskController desk;
/**
* The visit this row edits. The table sets it once.
*/
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* Creates a row that can remove its visit.
*
* @param desk the desk controller
*/
@Inject
public VisitRow(DeskController desk) {
this.desk = desk;
}
/**
* Returns the model property the table sets.
*
* @return the model property
*/
public ObjectProperty<Object> modelProperty() {
return model;
}
/**
* Returns the visit this row edits.
*
* @return the visit, or {@code null} when none is set
*/
public Object getModel() {
return model.get();
}
/**
* Sets the visit this row edits.
*
* @param model the visit
*/
public void setModel(Object model) {
this.model.set(model);
}
/**
* Removes this visit from the person.
*/
@FXML
public void remove() {
if (getModel() instanceof Address address) {
desk.removeVisit(address);
}
}
}
@@ -0,0 +1,54 @@
package de.neitzel.fx.example.view;
import de.neitzel.fx.view.Converter;
import jakarta.inject.Singleton;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
/**
* Translates a visit date between a {@link LocalDate} and the ISO text in the table row.
*/
@Singleton
public class VisitedOnConverter implements Converter {
/**
* Creates the converter. The container calls this constructor.
*/
public VisitedOnConverter() {
}
/**
* Writes the date as an ISO local date, or an empty string when the date is missing.
*
* @param attributeValue the visit date
* @return the text shown in the field
*/
@Override
public Object toControlValue(Object attributeValue) {
if (attributeValue == null) {
return "";
}
return attributeValue.toString();
}
/**
* Reads an ISO local date. An empty field clears the date.
*
* @param controlValue the field text
* @return the date, or {@code null} when the field is empty
* @throws IllegalArgumentException when the text is not an ISO local date
*/
@Override
public Object toAttributeValue(Object controlValue) {
String text = controlValue == null ? "" : controlValue.toString().trim();
if (text.isEmpty()) {
return null;
}
try {
return LocalDate.parse(text);
} catch (DateTimeParseException failure) {
throw new IllegalArgumentException("Visited on must be a date: " + text);
}
}
}
@@ -4,6 +4,7 @@
<?import javafx.scene.control.Label?> <?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?> <?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?> <?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.example.view.NoteForm?>
<?import de.neitzel.fx.view.BindingList?> <?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?> <?import de.neitzel.fx.view.DirectBinding?>
@@ -14,12 +15,17 @@
</padding> </padding>
<fx:define> <fx:define>
<BindingList> <BindingList>
<DirectBinding control="streetField" property="text" path="street" direction="both"/> <DirectBinding control="streetField" property="text" path="street" direction="both"
converter="de.neitzel.fx.example.view.StreetConverter"/>
<DirectBinding control="cityField" property="text" path="city" direction="both"/> <DirectBinding control="cityField" property="text" path="city" direction="both"/>
<DirectBinding control="noteForm" property="model" path="note" direction="both"/>
</BindingList> </BindingList>
</fx:define> </fx:define>
<Label fx:id="headingLabel" id="headingLabel"/>
<Label fx:id="openedAsLabel" id="openedAsLabel"/>
<Label text="Street"/> <Label text="Street"/>
<TextField fx:id="streetField" id="streetField"/> <TextField fx:id="streetField" id="streetField"/>
<Label text="City"/> <Label text="City"/>
<TextField fx:id="cityField" id="cityField"/> <TextField fx:id="cityField" id="cityField"/>
<NoteForm fx:id="noteForm"/>
</fx:root> </fx:root>
@@ -1,7 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<?import javafx.collections.FXCollections?>
<?import javafx.geometry.Insets?> <?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?> <?import javafx.scene.control.Button?>
<?import javafx.scene.control.CheckBox?>
<?import javafx.scene.control.ChoiceBox?>
<?import javafx.scene.control.ComboBox?> <?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.Label?> <?import javafx.scene.control.Label?>
<?import javafx.scene.control.ListView?> <?import javafx.scene.control.ListView?>
@@ -10,11 +13,14 @@
<?import javafx.scene.layout.GridPane?> <?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.HBox?> <?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?> <?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.example.model.Role?>
<?import de.neitzel.fx.example.view.AddressForm?> <?import de.neitzel.fx.example.view.AddressForm?>
<?import de.neitzel.fx.view.BindingList?> <?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.Column?>
<?import de.neitzel.fx.view.ComputedBinding?> <?import de.neitzel.fx.view.ComputedBinding?>
<?import de.neitzel.fx.view.DirectBinding?> <?import de.neitzel.fx.view.DirectBinding?>
<?import de.neitzel.fx.view.Repeat?> <?import de.neitzel.fx.view.Repeat?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml" <VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
fx:controller="de.neitzel.fx.example.view.DeskController" fx:controller="de.neitzel.fx.example.view.DeskController"
@@ -25,14 +31,20 @@
<fx:define> <fx:define>
<BindingList> <BindingList>
<DirectBinding control="nameField" property="text" path="name" direction="both"/> <DirectBinding control="nameField" property="text" path="name" direction="both"/>
<DirectBinding control="nameEcho" property="text" path="name" direction="view"/>
<DirectBinding control="nameDraft" property="text" path="name" direction="model"/>
<DirectBinding control="ageField" property="text" path="age" direction="both" <DirectBinding control="ageField" property="text" path="age" direction="both"
converter="de.neitzel.fx.example.view.AgeConverter"/> converter="de.neitzel.fx.example.view.AgeConverter"/>
<ComputedBinding control="greetingLabel" property="text" method="#greeting"/> <ComputedBinding control="greetingLabel" property="text" method="#greeting"/>
<DirectBinding control="titleLabel" property="text" path="title" direction="view"/>
<DirectBinding control="activeBox" property="selected" path="active" direction="both"/>
<DirectBinding control="roleChoice" property="value" path="role" direction="both"/>
<DirectBinding control="homeForm" property="model" path="home" direction="both"/> <DirectBinding control="homeForm" property="model" path="home" direction="both"/>
<DirectBinding control="homeStreetLabel" property="text" path="home.street" direction="view"/> <DirectBinding control="homeStreetLabel" property="text" path="home.street" direction="view"/>
<Repeat pane="visitPane" component="de.neitzel.fx.example.view.AddressForm" collection="visits"/> <Repeat pane="visitPane" component="de.neitzel.fx.example.view.VisitForm" collection="visits"/>
<DirectBinding control="visitChoice" property="items" path="visits" direction="view"/> <DirectBinding control="visitChoice" property="items" path="visits" direction="view"/>
<DirectBinding control="visitChoice" property="value" path="selectedVisit" direction="both"/> <DirectBinding control="visitChoice" property="value" path="selectedVisit" direction="both"/>
<DirectBinding control="visitTable" property="selection" path="selectedVisit" direction="both"/>
<DirectBinding control="tagList" property="items" path="tags" direction="view"/> <DirectBinding control="tagList" property="items" path="tags" direction="view"/>
<DirectBinding control="noticeLabel" property="text" path="latestMessage" direction="view"/> <DirectBinding control="noticeLabel" property="text" path="latestMessage" direction="view"/>
</BindingList> </BindingList>
@@ -46,14 +58,30 @@
</columnConstraints> </columnConstraints>
<Label text="Name" GridPane.rowIndex="0" GridPane.columnIndex="0"/> <Label text="Name" GridPane.rowIndex="0" GridPane.columnIndex="0"/>
<TextField fx:id="nameField" id="nameField" GridPane.rowIndex="0" GridPane.columnIndex="1"/> <TextField fx:id="nameField" id="nameField" GridPane.rowIndex="0" GridPane.columnIndex="1"/>
<Label text="Age" GridPane.rowIndex="1" GridPane.columnIndex="0"/> <Label text="Shown name" GridPane.rowIndex="1" GridPane.columnIndex="0"/>
<TextField fx:id="nameEcho" id="nameEcho" GridPane.rowIndex="1" GridPane.columnIndex="1"/>
<Label text="Name draft" GridPane.rowIndex="2" GridPane.columnIndex="0"/>
<TextField fx:id="nameDraft" id="nameDraft" GridPane.rowIndex="2" GridPane.columnIndex="1"/>
<Label text="Age" GridPane.rowIndex="3" GridPane.columnIndex="0"/>
<TextField fx:id="ageField" id="ageField" promptText="years 36" <TextField fx:id="ageField" id="ageField" promptText="years 36"
GridPane.rowIndex="1" GridPane.columnIndex="1"/> GridPane.rowIndex="3" GridPane.columnIndex="1"/>
<Label text="Greeting" GridPane.rowIndex="2" GridPane.columnIndex="0"/> <Label text="Greeting" GridPane.rowIndex="4" GridPane.columnIndex="0"/>
<Label fx:id="greetingLabel" id="greetingLabel" GridPane.rowIndex="2" GridPane.columnIndex="1"/> <Label fx:id="greetingLabel" id="greetingLabel" GridPane.rowIndex="4" GridPane.columnIndex="1"/>
<Label text="Home street" GridPane.rowIndex="3" GridPane.columnIndex="0"/> <Label text="Title" GridPane.rowIndex="5" GridPane.columnIndex="0"/>
<Label fx:id="homeStreetLabel" id="homeStreetLabel" GridPane.rowIndex="3" GridPane.columnIndex="1"/> <Label fx:id="titleLabel" id="titleLabel" GridPane.rowIndex="5" GridPane.columnIndex="1"/>
<Label text="Role" GridPane.rowIndex="6" GridPane.columnIndex="0"/>
<ChoiceBox fx:id="roleChoice" id="roleChoice" GridPane.rowIndex="6" GridPane.columnIndex="1">
<items>
<FXCollections fx:factory="observableArrayList">
<Role fx:constant="READER"/>
<Role fx:constant="AUTHOR"/>
</FXCollections>
</items>
</ChoiceBox>
<Label text="Home street" GridPane.rowIndex="7" GridPane.columnIndex="0"/>
<Label fx:id="homeStreetLabel" id="homeStreetLabel" GridPane.rowIndex="7" GridPane.columnIndex="1"/>
</GridPane> </GridPane>
<CheckBox fx:id="activeBox" id="activeBox" text="Active"/>
<Label text="Home"/> <Label text="Home"/>
<AddressForm fx:id="homeForm"/> <AddressForm fx:id="homeForm"/>
@@ -63,6 +91,12 @@
<ComboBox fx:id="visitChoice" id="visitChoice" HBox.hgrow="ALWAYS" maxWidth="Infinity"/> <ComboBox fx:id="visitChoice" id="visitChoice" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
<Button text="Add visit" onAction="#addVisit"/> <Button text="Add visit" onAction="#addVisit"/>
</HBox> </HBox>
<Table fx:id="visitTable" id="visitTable" collection="visits"
row="de.neitzel.fx.example.view.VisitRow" prefWidth="496" prefHeight="160">
<Column header="Street" attribute="street" width="300"/>
<Column header="City" attribute="city"/>
<Column header="Visited" attribute="visitedOn" width="120"/>
</Table>
<VBox fx:id="visitPane" id="visitPane" spacing="4"/> <VBox fx:id="visitPane" id="visitPane" spacing="4"/>
<Label text="Tags"/> <Label text="Tags"/>
@@ -74,5 +108,9 @@
<Label text="Notice"/> <Label text="Notice"/>
<Label fx:id="noticeLabel" id="noticeLabel" wrapText="true"/> <Label fx:id="noticeLabel" id="noticeLabel" wrapText="true"/>
<HBox spacing="8">
<Button text="Leave a note" onAction="#leaveNote"/> <Button text="Leave a note" onAction="#leaveNote"/>
<Button text="Leave a warning" onAction="#leaveWarning"/>
<Button text="Leave a fatal notice" onAction="#leaveFatal"/>
</HBox>
</VBox> </VBox>
@@ -0,0 +1,18 @@
<?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?>
<fx:root type="javafx.scene.layout.VBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" spacing="6">
<fx:define>
<BindingList>
<DirectBinding control="noteField" property="text" path="text" direction="both"/>
</BindingList>
</fx:define>
<Label text="Note"/>
<TextField fx:id="noteField" id="noteField"/>
</fx:root>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.geometry.Insets?>
<?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?>
<fx:root type="javafx.scene.layout.VBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" spacing="6">
<padding>
<Insets top="4" right="4" bottom="4" left="4"/>
</padding>
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="street" direction="both"
converter="de.neitzel.fx.example.view.StreetConverter"/>
<DirectBinding control="cityField" property="text" path="city" direction="both"/>
</BindingList>
</fx:define>
<Label text="Street"/>
<TextField fx:id="streetField" id="streetField"/>
<Label text="City"/>
<TextField fx:id="cityField" id="cityField"/>
</fx:root>
@@ -0,0 +1,23 @@
<?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="8">
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="street" direction="both"/>
<DirectBinding control="cityField" property="text" path="city" direction="both"/>
<DirectBinding control="visitedField" property="text" path="visitedOn" direction="both"
converter="de.neitzel.fx.example.view.VisitedOnConverter"/>
</BindingList>
</fx:define>
<TextField fx:id="streetField" id="streetField"/>
<TextField fx:id="cityField" id="cityField"/>
<TextField fx:id="visitedField" id="visitedField"/>
<Button text="Remove" onAction="#remove"/>
</fx:root>
@@ -1,17 +1,27 @@
package de.neitzel.fx.example; package de.neitzel.fx.example;
import de.neitzel.fx.example.model.Person; import de.neitzel.fx.example.model.Person;
import de.neitzel.fx.example.model.Role;
import de.neitzel.fx.example.view.AddressForm; import de.neitzel.fx.example.view.AddressForm;
import de.neitzel.fx.example.view.DeskController; import de.neitzel.fx.example.view.DeskController;
import de.neitzel.fx.example.view.PersonViewModel;
import de.neitzel.fx.example.view.VisitForm;
import de.neitzel.fx.example.view.VisitRow;
import de.neitzel.fx.view.LoadedView; import de.neitzel.fx.view.LoadedView;
import de.neitzel.fx.view.ViewLoader; import de.neitzel.fx.view.ViewLoader;
import de.neitzel.injection.ApplicationContext; import de.neitzel.injection.ApplicationContext;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.Parent; import javafx.scene.Parent;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ChoiceBox;
import javafx.scene.control.Label; import javafx.scene.control.Label;
import javafx.scene.control.ListView; import javafx.scene.control.ListView;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.TextField; import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -21,7 +31,12 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull; 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; import static org.junit.jupiter.api.Assertions.fail;
/** /**
@@ -29,6 +44,9 @@ import static org.junit.jupiter.api.Assertions.fail;
*/ */
class DeskLoadsTest { class DeskLoadsTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll @BeforeAll
static void startJavaFx() throws InterruptedException { static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1); CountDownLatch started = new CountDownLatch(1);
@@ -42,50 +60,342 @@ class DeskLoadsTest {
} }
} }
/**
* The desk shows the sample person, including directions, the title, and the home note.
*/
@Test @Test
void deskEditsThePersonAndKeepsTheGreetingCurrent() throws Exception { void deskShowsThePerson() throws Exception {
onFx(() -> { onFx(() -> {
Person person = DeskApp.sample(); Loaded loaded = load();
ApplicationContext context = new ApplicationContext(DeskApp.class); Parent root = loaded.root();
LoadedView loaded = ViewLoader.load(DeskController.class.getResource("Desk.fxml"), person, context); AddressForm home = homeForm(root);
Parent root = loaded.getRoot();
TextField nameField = (TextField) root.lookup("#nameField"); assertEquals("Ada Lovelace", text(root, "nameField"));
TextField ageField = (TextField) root.lookup("#ageField"); assertEquals("Ada Lovelace", text(root, "nameEcho"));
Label greeting = (Label) root.lookup("#greetingLabel"); assertEquals("Ada Lovelace", text(root, "nameDraft"));
Label homeStreet = (Label) root.lookup("#homeStreetLabel"); assertEquals("Hello, Ada Lovelace (36)", text(root, "greetingLabel"));
TextField street = (TextField) homeForm(root).lookup("#streetField"); assertEquals("years 36", text(root, "ageField"));
VBox visits = (VBox) root.lookup("#visitPane"); assertEquals("titled-Ada Lovelace", text(root, "titleLabel"));
ListView<?> tags = (ListView<?>) root.lookup("#tagList"); assertTrue(((CheckBox) root.lookup("#activeBox")).isSelected());
assertEquals(Role.AUTHOR, ((ChoiceBox<?>) root.lookup("#roleChoice")).getValue());
assertEquals("Ada Lovelace", nameField.getText()); assertEquals("Sample Street 1", text(home, "streetField"));
assertEquals("Hello, Ada Lovelace", greeting.getText()); assertEquals("Sample City", text(home, "cityField"));
assertEquals("years 36", ageField.getText()); assertEquals("Sample Street 1", text(root, "homeStreetLabel"));
assertEquals("Sample Street 1", street.getText()); assertEquals("Address", text(home, "headingLabel"));
assertEquals("Sample Street 1", homeStreet.getText()); assertEquals("Opened as Sample City", text(home, "openedAsLabel"));
assertEquals(1, visits.getChildren().size()); assertEquals("Kept at home", text(home, "noteField"));
assertEquals(2, tags.getItems().size()); assertEquals(3, ((VBox) root.lookup("#visitPane")).getChildren().size());
assertEquals(3, tableBody(root).getChildren().size());
nameField.setText("Grace"); assertEquals("Street", headerText(root, 0));
assertEquals("Grace", person.getName()); assertEquals("City", headerText(root, 1));
assertEquals("Hello, Grace", greeting.getText()); assertEquals("Visited", headerText(root, 2));
HBox header = tableHeader(root);
ageField.setText("years 41"); assertEquals(300, ((Region) header.getChildren().get(0)).getPrefWidth());
assertEquals(Integer.valueOf(41), person.getAge()); assertEquals(76, ((Region) header.getChildren().get(1)).getPrefWidth());
ageField.setText("nope"); assertEquals(120, ((Region) header.getChildren().get(2)).getPrefWidth());
assertEquals(Integer.valueOf(41), person.getAge()); assertEquals(8, header.getSpacing());
assertNotNull(loaded.getViewModel().getLatestMessage()); VisitRow first = (VisitRow) tableBody(root).getChildren().get(0);
assertEquals("age", loaded.getViewModel().getLatestMessage().getSource()); assertSame(loaded.person().getVisits().get(0), first.getModel());
assertEquals("Work Road 2", text(first, "streetField"));
street.setText("Other Street"); assertEquals("Office City", text(first, "cityField"));
assertEquals("Other Street", person.getHome().getStreet()); assertEquals("2020-01-02", text(first, "visitedField"));
assertEquals("Other Street", homeStreet.getText()); assertTrue(first.getStyleClass().contains("selected"));
assertEquals(2, ((ListView<?>) root.lookup("#tagList")).getItems().size());
person.getTags().add("fx"); assertInstanceOf(PersonViewModel.class, loaded.view().getViewModel());
assertEquals(3, tags.getItems().size());
}); });
} }
/**
* A later call to the entity setter leaves the controls on the view-model value.
*/
@Test
void entitySetterLeavesTheControls() throws Exception {
onFx(() -> {
Loaded loaded = load();
loaded.person().setName("Ignored");
assertEquals("Ignored", loaded.person().getName());
assertEquals("Ada Lovelace", text(loaded.root(), "nameField"));
assertEquals("Ada Lovelace", text(loaded.root(), "nameEcho"));
assertEquals("Ada Lovelace", text(loaded.root(), "nameDraft"));
});
}
/**
* A second view of the same person reuses the view model, so a name edit shows on both.
* <p>
* The second document is only a name field. Loading the desk again would rebuild the home
* address form, and that form is the cached singleton already showing.
*/
@Test
void secondViewReusesTheViewModel() throws Exception {
onFx(() -> {
Loaded first = load();
LoadedView second = ViewLoader.load(
DeskLoadsTest.class.getResource("NameLine.fxml"), first.person(), first.context());
assertSame(first.view().getViewModel(), second.getViewModel());
textField(first.root(), "nameField").setText("Grace");
assertEquals("Grace", first.person().getName());
assertEquals("Grace", text(second.getRoot(), "nameField"));
assertEquals("titled-Grace", text(first.root(), "titleLabel"));
});
}
/**
* Both, view, and model directions on the name, and the title follows the view model.
*/
@Test
void nameDirectionsUpdateTheTitleAndTheGreeting() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
textField(root, "nameField").setText("Grace");
assertEquals("Grace", loaded.person().getName());
assertEquals("Grace", text(root, "nameEcho"));
assertEquals("Ada Lovelace", text(root, "nameDraft"));
assertEquals("Hello, Grace (36)", text(root, "greetingLabel"));
assertEquals("titled-Grace", text(root, "titleLabel"));
textField(root, "nameEcho").setText("Hopper");
assertEquals("Grace", loaded.person().getName());
assertEquals("Grace", text(root, "nameField"));
textField(root, "nameDraft").setText("Byron");
assertEquals("Byron", loaded.person().getName());
assertEquals("Byron", text(root, "nameField"));
assertEquals("Byron", text(root, "nameEcho"));
assertEquals("titled-Byron", text(root, "titleLabel"));
});
}
/**
* A bad age keeps the previous value, records one error, and a later success clears it.
* Changing the age also refreshes the greeting.
*/
@Test
void ageConverterRecordsAndClearsAFailure() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
TextField age = textField(root, "ageField");
age.setText("years 41");
assertEquals(Integer.valueOf(41), loaded.person().getAge());
assertEquals("Hello, Ada Lovelace (41)", text(root, "greetingLabel"));
age.setText("nope");
assertEquals(Integer.valueOf(41), loaded.person().getAge());
assertEquals("nope", age.getText());
assertEquals("age", loaded.view().getViewModel().getLatestMessage().getSource());
assertEquals(de.neitzel.fx.view.Message.Severity.ERROR,
loaded.view().getViewModel().getLatestMessage().getSeverity());
assertEquals(loaded.view().getViewModel().getLatestMessage().getText(), text(root, "noticeLabel"));
age.setText("years 41");
assertNull(loaded.view().getViewModel().getLatestMessage());
assertNull(text(root, "noticeLabel"));
});
}
/**
* An empty home street is carried onto the person as {@code home.street}, then cleared.
*/
@Test
void emptyStreetIsCarriedFromTheHomeAddress() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
AddressForm home = homeForm(root);
TextField street = textField(home, "streetField");
street.setText(" ");
assertEquals("Sample Street 1", loaded.person().getHome().getStreet());
assertEquals(" ", street.getText());
assertEquals("Sample Street 1", text(root, "homeStreetLabel"));
assertEquals("home.street", loaded.view().getViewModel().getLatestMessage().getSource());
assertEquals("Street is required", text(root, "noticeLabel"));
street.setText("Other Street");
assertEquals("Other Street", loaded.person().getHome().getStreet());
assertEquals("Other Street", text(root, "homeStreetLabel"));
assertNull(loaded.view().getViewModel().getLatestMessage());
textField(home, "cityField").setText("Other City");
assertEquals("Opened as Sample City", text(home, "openedAsLabel"));
textField(home, "noteField").setText("Moved");
assertEquals("Moved", loaded.person().getHome().getNote().getText());
});
}
/**
* Editing a table row updates the visit and the repeated form. Removing the row drops it.
*/
@Test
void tableRowEditsTheVisitAndRemovesIt() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
VBox visits = (VBox) root.lookup("#visitPane");
VisitRow first = (VisitRow) tableBody(root).getChildren().get(0);
textField(first, "streetField").setText("Changed Road");
assertEquals("Changed Road", loaded.person().getVisits().get(0).getStreet());
VisitForm repeated = (VisitForm) visits.getChildren().get(0);
assertEquals("Changed Road", text(repeated, "streetField"));
ListView<?> tags = (ListView<?>) root.lookup("#tagList");
loaded.person().getTags().add("fx");
assertEquals(3, tags.getItems().size());
Button remove = (Button) first.lookup(".button");
assertEquals(Region.USE_COMPUTED_SIZE, remove.getPrefWidth());
remove.fire();
assertEquals(2, loaded.person().getVisits().size());
assertNull(loaded.person().getSelectedVisit());
assertEquals(2, visits.getChildren().size());
assertEquals(2, tableBody(root).getChildren().size());
});
}
/**
* Info, warning, and fatal notices show on the notice label.
*/
@Test
void noticesShowInfoWarningAndFatal() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
DeskController desk = loaded.context().getComponent(DeskController.class);
desk.leaveNote();
assertEquals(de.neitzel.fx.view.Message.Severity.INFO,
loaded.view().getViewModel().getLatestMessage().getSeverity());
assertEquals("The model already has the name Ada Lovelace.", text(root, "noticeLabel"));
desk.leaveWarning();
assertEquals(de.neitzel.fx.view.Message.Severity.WARN,
loaded.view().getViewModel().getLatestMessage().getSeverity());
assertEquals("Check the age before leaving the desk.", text(root, "noticeLabel"));
desk.leaveFatal();
assertEquals(de.neitzel.fx.view.Message.Severity.FATAL,
loaded.view().getViewModel().getLatestMessage().getSeverity());
assertEquals("The desk cannot store this person.", text(root, "noticeLabel"));
});
}
/**
* The active flag and the role write back to the person.
*/
@Test
void activeAndRoleWriteBack() throws Exception {
onFx(() -> {
Loaded loaded = load();
Parent root = loaded.root();
CheckBox active = (CheckBox) root.lookup("#activeBox");
active.setSelected(false);
assertFalse(loaded.person().isActive());
@SuppressWarnings("unchecked")
ChoiceBox<Role> role = (ChoiceBox<Role>) root.lookup("#roleChoice");
role.setValue(Role.READER);
assertEquals(Role.READER, loaded.person().getRole());
});
}
/**
* One loaded desk: the person, the view, and the container that built it.
*
* @param person the model
* @param view the loaded view
* @param context the container
*/
private record Loaded(Person person, LoadedView view, ApplicationContext context) {
/**
* Returns the scene-graph root.
*
* @return the root
*/
private Parent root() {
return view.getRoot();
}
}
/**
* Loads the desk for a fresh sample person.
*
* @return the loaded desk
*/
private static Loaded load() {
Person person = DeskApp.sample();
ApplicationContext context = new ApplicationContext(DeskApp.class);
LoadedView view = ViewLoader.load(DeskController.class.getResource("Desk.fxml"), person, context);
return new Loaded(person, view, context);
}
/**
* Returns the text of a labeled control.
*
* @param parent the parent to search
* @param id the control id
* @return the text
*/
private static String text(Parent parent, String id) {
Node node = parent.lookup("#" + id);
if (node instanceof TextField field) {
return field.getText();
}
if (node instanceof Label label) {
return label.getText();
}
fail("no text on " + id);
return null;
}
/**
* Returns a text field.
*
* @param parent the parent to search
* @param id the field id
* @return the field
*/
private static TextField textField(Parent parent, String id) {
return (TextField) parent.lookup("#" + id);
}
/**
* Returns the visit table body.
*
* @param root the loaded scene
* @return the rows currently showing
*/
private static VBox tableBody(Parent root) {
ScrollPane scroll = (ScrollPane) root.lookup("#visitTable").lookup(".table-scroll");
return (VBox) scroll.getContent();
}
/**
* Returns the header text of one visit-table column.
*
* @param root the loaded scene
* @param index the column index
* @return the header text
*/
private static String headerText(Parent root, int index) {
Label label = (Label) tableHeader(root).getChildren().get(index).lookup(".column-header");
return label.getText();
}
/**
* Returns the visit-table header.
*
* @param root the loaded scene
* @return the header
*/
private static HBox tableHeader(Parent root) {
ScrollPane headerScroll = (ScrollPane) root.lookup("#visitTable").lookup(".table-header-scroll");
return (HBox) headerScroll.getContent();
}
/**
* Returns the home-address component in the loaded desk.
*
* @param root the loaded scene
* @return the home-address component
*/
private static AddressForm homeForm(Parent root) { private static AddressForm homeForm(Parent root) {
for (Node child : root.getChildrenUnmodifiable()) { for (Node child : root.getChildrenUnmodifiable()) {
if (child instanceof AddressForm form) { if (child instanceof AddressForm form) {
@@ -96,6 +406,11 @@ class DeskLoadsTest {
return null; return null;
} }
/**
* Runs one check on the JavaFX application thread.
*
* @param check the check
*/
private static void onFx(Runnable check) throws InterruptedException { private static void onFx(Runnable check) throws InterruptedException {
CountDownLatch done = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(1);
AtomicReference<Throwable> failure = new AtomicReference<>(); AtomicReference<Throwable> failure = new AtomicReference<>();
@@ -0,0 +1,15 @@
<?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="nameField" property="text" path="name" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="nameField" id="nameField"/>
</VBox>
+361 -110
View File
@@ -1,56 +1,35 @@
# Using fx # Using fx
`fx` binds an FXML View to an ordinary Java Model. The Model stays a JavaBean. The library builds one ViewModel for that instance, loads the View, and applies the binding elements declared in the FXML. ## Introduction
The worked example is the `fx-example` module, package `de.neitzel.fx.example`. From the NeitzelLib root: `fx` binds an FXML view to an ordinary Java model. The model stays a JavaBean. The library builds one view model for that instance, loads the view, and applies the binding elements declared in the FXML.
You write the model, the FXML, and, where a screen needs them, a controller, reusable components, and converters. The library creates the view model. The [injection](../injection/README.md) container creates the controller, each component, and each converter.
The worked example is the `fx-example` module, package `de.neitzel.fx.example`. From the NeitzelLib root, with Java 21:
```bash ```bash
mvn -pl fx-example javafx:run mvn -pl fx-example -am javafx:run
``` ```
The desk edits one person: name, age, greeting, home address, visits, and tags. The launch class is `de.neitzel.fx.example.Main`. `Main` does not extend `Application`. It calls `Application.launch(DeskApp.class, args)`, and `DeskApp.start` builds the scene on the JavaFX thread.
## What you write Depend on `de.neitzel.lib:fx`. That module depends on `injection` and on JavaFX base, controls, graphics, fxml, and web. Application types the container constructs use `jakarta.inject.Singleton` or `jakarta.inject.Named`.
| Piece | Role | ## Quick start
| --- | --- |
| Model | A JavaBean. Read-write getters and setters are the attributes the View can bind. |
| FXML | The View. Binding elements live in an `fx:define` list, so they are not scene-graph children. |
| Controller | Optional. A `@Singleton` the container creates. Computed bindings call its methods. |
| Component | A reusable fragment: one class, one `ClassName.fxml` in the same package, one model property. |
| Converter | Optional. Translates one Direct binding between the attribute value and the control value. |
| `ApplicationContext` | The existing injection container. It creates the Controller, each Component, and each Converter. |
The library creates the ViewModel. The container does not. `DeskApp` scans its package, loads one person into `Desk.fxml`, and asks for a dialog when a new message is added:
## A Model
A read-write attribute is a getter and a setter, and it is not declared on `Object`. Strings, numbers, booleans, enums, and date/time values stay simple values. A type that itself has a read-write attribute is a complex attribute: the ViewModel holds the ViewModel of that nested instance. A `List` of either kind is a list, not a nested ViewModel.
`Person` and `Address` in the example are Models. They mention no JavaFX type.
Start lists as a mutable `ArrayList`. After the View loads, the Model's list attribute is the `ObservableList` the View is watching. Add and remove on that list. A later call to the setter leaves the View on the list it already holds.
## Load a View
```java ```java
ApplicationContext context = new ApplicationContext(DeskApp.class); ApplicationContext context = new ApplicationContext(DeskApp.class);
Person person = DeskApp.sample(); Person person = DeskApp.sample();
LoadedView loaded = ViewLoader.load( LoadedView loaded = ViewLoader.load(
DeskController.class.getResource("Desk.fxml"), person, context); DeskController.class.getResource("Desk.fxml"), person, context);
MessagePopup.showNewMessages(loaded.getViewModel());
Parent root = loaded.getRoot(); Parent root = loaded.getRoot();
ViewModel viewModel = loaded.getViewModel();
``` ```
`ApplicationContext` scans the package of the class you pass, and its subpackages, for `@Singleton` and `@Named`. `ViewLoader.load` reuses the ViewModel already built for that instance, or builds one, then loads the FXML and applies the binding elements. `Person` mentions no JavaFX type. `Desk.fxml` names `DeskController` and lists the bindings in `fx:define`, so they are not scene-graph children. A name field is a direct binding:
One Model instance has one ViewModel. A second View of the same instance reuses it, so the two Views stay aligned. The first View fixes the ViewModel class. A later View that names a different class fails to load.
Put the JavaFX launch in a class that does not extend `Application`, and call `Application.launch` from there. `Main` in the example does that. The scene is built on the JavaFX thread, which is where `start` runs.
## Direct binding
A Direct binding names a control by `fx:id`, a JavaFX property of that control, a Path, and a direction.
```xml ```xml
<fx:define> <fx:define>
@@ -61,34 +40,143 @@ A Direct binding names a control by `fx:id`, a JavaFX property of that control,
<TextField fx:id="nameField"/> <TextField fx:id="nameField"/>
``` ```
The Path `name` is the attribute of this View's ViewModel. `home.street` steps through the home ViewModel, and the direction applies to `street`. When a complex attribute along the Path is null, that binding is left unapplied and both sides keep their current values. `ApplicationContext` scans the package of the class you pass, and its subpackages. `DeskApp` carries `@Config(basePackage = "de.neitzel.fx.example")`, which is that package. `ViewLoader.load` reuses the view model already built for that person, or builds one, then loads the FXML and applies the binding elements.
One model instance has one view model. The first view fixes the view model class. The desk's controller names `PersonViewModel`, so that is the class for this person.
## The person desk
The desk edits one person: name, age, greeting, title, role, active flag, home address, visits, and tags. The visits appear again in a table, with one row per visit, and again as repeated forms.
| What you see | Feature | Where it is written |
| --- | --- | --- |
| Name | Direct binding, `both` | `Desk.fxml`, `nameField` |
| Shown name | Direct binding, `view`, on an editable field | `nameEcho` |
| Name draft | Direct binding, `model` | `nameDraft` |
| Age, shown as `years 36` | Converter, constructor injection, `@Named` service | `AgeConverter`, `AgeSuffix` |
| Greeting | Computed binding that reads the name field and the age | `DeskController.greeting` |
| Title `titled-Ada Lovelace` | View model subclass | `PersonViewModel`, `@ViewModelClass` on the controller |
| Active | Boolean attribute on a check box | `Person.active` |
| Role | Enum attribute on a choice box | `Person.role`, `Role` |
| Home address | Component, setter injection, `HasViewModel` | `AddressForm` |
| Note under the home address | Nested component | `NoteForm` inside `AddressForm.fxml` |
| Home street label | Path `home.street`, `view` | `homeStreetLabel` |
| Empty street | Converter failure carried as `home.street` | `StreetConverter`, field injection of `StreetPolicy` |
| Visit forms | Repeat | `VisitForm` in `visitPane` |
| Visit combo and table selection | One `selectedVisit` attribute | `visitChoice`, `visitTable` |
| Visit table | Columns, a date, a Remove button after the fields | `Table`, `VisitRow`, `VisitedOnConverter` |
| Tags | List of simple values | `tagList` |
| Notice, and the three buttons | Messages, including info, warning, and fatal | `noticeLabel`, `DeskController` |
`DeskLoadsTest` loads this same desk and walks those bindings.
## Model
A read-write attribute is a getter and a setter, and it is not declared on `Object`. `Person`, `Address`, `Note`, and `Role` are models.
| Declared type | What the view model stores |
| --- | --- |
| `String` | `StringProperty` |
| `int`, `Integer` | `IntegerProperty`. A null `Integer` becomes 0 |
| `long`, `Long` | `LongProperty`. A null `Long` becomes 0 |
| `float`, `Float`, `double`, `Double` | The matching primitive property. A null value becomes 0 |
| `boolean`, `Boolean` | `BooleanProperty`. A null `Boolean` becomes false |
| anything else | `ObjectProperty` holding the value, including null |
Primitives, enums, arrays, `String`, `Boolean`, `Number`, `Collection`, `Map`, `Date`, `Calendar`, and any type in a `java.time` package stay simple values, even when they have setters. The desk uses that for `name`, `age`, `active`, `role`, `tags`, and `visitedOn`.
A type that itself has a read-write attribute is complex. The view model holds the view model of that nested instance, or null. `Person.home` is an `Address`. `Address.note` is a `Note`. A `List` of either kind is a list, not a nested view model. `visits` is a list of addresses. `tags` is a list of strings.
Instances are compared by identity. One person has one view model. The same address object on two attributes would be one view model, so an edit through either attribute would show on the other. Two equal addresses that are different objects keep two view models. A cycle, such as a person who points at themselves, reuses the view model already under construction.
Start lists as a mutable `ArrayList`, as `Person` does for `visits` and `tags`. After the view loads, the model's list attribute is the `ObservableList` the view is watching. Add and remove on that list. The view model writes that observable list back through the setter when the setter's parameter type can accept it. A `List` setter does. A setter typed as `ArrayList` or `Set` does not, and the property then diverges from the field.
After the view model exists, it is the writer. A property change calls the setter. A later call to the entity setter leaves the control on the value the view model holds. `DeskLoadsTest.entitySetterLeavesTheControls` sets the person's name to `Ignored` and the name field stays `Ada Lovelace`. The next edit of the field writes through the view model and replaces that name.
## Load a view
```java
public static LoadedView load(URL fxml, Object model, ApplicationContext context)
```
The load reads `fx:controller` from the document first, so it can see `@ViewModelClass`, and builds or reuses the view model. It then loads the FXML. The container supplies the controller. Components come from the container as well. The root must be a `Parent`. Bindings run after `@FXML` fields are filled. Tables under the root open last, including a table inside a scroll pane.
`LoadedView.getRoot()` is the scene graph. `getViewModel()` returns the view model when it is a `ViewModel`, which includes a subclass such as `PersonViewModel`. A replacement that does not extend `ViewModel` makes `getViewModel()` throw `IllegalStateException` with the message `View is bound to` and the class name. Use `property(String)` in that case. The desk's view model is a subclass, so `getViewModel()` returns it.
`LoadedView.property(String)` returns the JavaFX property for an attribute name, for `latestMessage`, or for a `nameProperty()` method on the view model. A missing name returns null. A binding whose path does not resolve fails the load.
FXML that cannot be read becomes `IllegalStateException` `Could not load view` and the URL. A missing controller class is `IllegalArgumentException` `No controller` and the name.
`DeskLoadsTest.secondViewReusesTheViewModel` loads a second document, `NameLine.fxml`, for the same person. The second document names no controller and no view model class. It reuses `PersonViewModel`. Typing in the desk's name field updates the second field, and the title becomes `titled-Grace`. Loading `Desk.fxml` a second time while the first desk is showing does not work: the home `AddressForm` is the cached singleton and already has children. See [Component](#component).
## Direct binding
A direct binding names a control by `fx:id`, a JavaFX property of that control, a path, and a direction. The property is resolved as `propertyName + "Property"`, so `text` means `textProperty()`. An optional `converter` is a class name.
```xml
<DirectBinding control="nameField" property="text" path="name" direction="both"/>
```
For `both` and `model`, the write happens when the JavaFX property changes, including each change of a text property. There is no separate save. `view` does not write the attribute.
`BindingList` is the element that holds direct bindings, computed bindings, and repeats. Its default property is `elements`, so the children in FXML are the list. An unknown child fails the load with `Not a binding element:`. The children that apply are a direct binding, a computed binding, and a repeat.
When the attribute value is already an `ObservableList`, the control property is set to that same list. Direction and converter are not consulted. The tag binding names `direction="view"` and still shares the list. See [A list of simple values](#a-list-of-simple-values).
A binding that names no such control fails with `No control "` and the id. A missing `xxxProperty()` method fails with `No property` and the property name. A method that returns something other than a `Property` fails with `did not return a property`.
## Directions
| Direction | What it does | | Direction | What it does |
| --- | --- | | --- | --- |
| `both` | Shows the attribute and writes it when the control changes. | | `both` | Shows the attribute and writes it when the control changes. |
| `view` | Shows the attribute and follows later changes of the JavaFX property. Edits to the control stay on the control. | | `view` | Shows the attribute and follows later changes of the JavaFX property. Edits to the control stay on the control. |
| `model` | Shows the current value, writes the attribute when the control changes, and does not push later property changes back to the control. | | `model` | Shows the current value, writes the attribute when the control changes, and leaves the control where it is when the attribute changes later. |
The write happens when the JavaFX property changes, including each change of a text property. There is no separate save for that write. After the ViewModel exists, it is the writer: a later call to the entity setter leaves the control on the value the ViewModel holds. The desk puts all three on `name`. The name field is `both`. Shown name (`nameEcho`) is `view`: typing there leaves `Person.name` and the name field unchanged, and typing in the name field updates the echo. Name draft (`nameDraft`) is `model`: it opens as `Ada Lovelace`, stays there while the name field changes, and typing in the draft writes `Person.name` and updates the name field and the echo.
A Path is resolved when the binding is applied. A binding whose last segment is a complex attribute follows later changes of that attribute and passes the Model instance to the control. A longer Path stays with the child ViewModel that was in place when the binding was applied. The desk's "Home street" label is `home.street` toward the View. The home address itself is not swapped out from under that label. A direction other than `view`, `model`, or `both` fails the load with `Not a direction:`.
A list is not indexed in a Path. Repeat a Component, or hand the list to a control. For a complex last segment the control receives the model instance behind the view model, and the same three directions apply to later changes of that attribute. The home form's `model` property is `home` in both directions, so the form edits that address and a later replacement of `home` would follow. The desk sets the home address once.
## Paths
A path is attribute names separated by `.`. One name is an attribute of this view's view model, or `latestMessage`, or a `nameProperty()` on the view model. Earlier names must be complex attributes. The direction applies to the last name.
`home.street` on the home street label steps through the home view model, and `view` applies to `street`. When a complex attribute along the path is null, that binding is left unapplied and both sides keep their current values. The load still succeeds.
The path is resolved when the binding is applied. A binding whose last segment is complex follows later changes of that attribute. A longer path stays with the child view model that was in place when the binding was applied. The home street label is `home.street`. Replacing `person.home` with another address later would leave that label on the street it was bound to.
A list is not indexed in a path. Repeat a component, place a table, or hand a list of simple values to a control.
A path that is not a read-write attribute fails the load with `No read-write attribute for path "` and the path. `Object.getClass` is not an attribute, and a getter without a setter is not one either.
## Converter ## Converter
A text field edits an `int` through a Converter. The container creates the class and injects its services. `toControlValue` runs toward the View. `toAttributeValue` runs only when the direction is `model` or `both`. A toward-view binding does not need the reverse method. A converter translates one direct binding between the attribute value and the control value. The container creates the class and injects its services. The class must implement `de.neitzel.fx.view.Converter`. Otherwise the load fails with `Not a converter:` or `No converter`.
`toControlValue` runs toward the view, including the value shown at load. `toAttributeValue` runs only when the direction is `model` or `both`. A toward-view binding does not call it, and the default method throws `UnsupportedOperationException`.
The age field is the desk's `both` converter. `AgeConverter` takes `AgeSuffix` in its constructor. `AgeSuffix` is `@Named` and `prefix()` returns `years `. Age 36 shows as `years 36`. Typing `years 41` sets the age to 41.
```xml ```xml
<DirectBinding control="ageField" property="text" path="age" direction="both" <DirectBinding control="ageField" property="text" path="age" direction="both"
converter="de.neitzel.fx.example.view.AgeConverter"/> converter="de.neitzel.fx.example.view.AgeConverter"/>
``` ```
When a conversion throws, the other side keeps its previous value. The ViewModel records one error Message whose source is the attribute name (`age` on the desk). Further failures of that binding update the Message in place. A later success removes it. `StreetConverter` is the other shape: a no-arg constructor and a private `@Inject StreetPolicy` field. `toAttributeValue` trims the text and throws `IllegalArgumentException` with `Street is required` when the result is empty. The home form and each visit form use it on `street`.
`VisitedOnConverter` turns a `LocalDate` into ISO text in the table, and `LocalDate.parse` back. An empty field writes null.
A converter is not applied to a complex attribute or to an `ObservableList`. Those bindings assign the model instance or the list.
When a conversion throws, the other side keeps its previous value. The control keeps the text that was typed, because the failed write does not replace it. The view model that owns the last path segment records one error message. The source is that segment's name. The text is the exception message, or `Conversion failed` when the exception message is null or empty. A later failure of that same binding updates the message in place. A later success removes it.
On the desk, `nope` in the age field leaves the age at 41, leaves the field text `nope`, and sets `latestMessage` to an error whose source is `age`. Typing `years 41` again clears it. A blank home street leaves the street as it was and records source `home.street` on the person, because the failure is owned by the address and carried onto the parent. The notice label shows `Street is required`. A later real street clears that notice. See [Messages](#messages).
## Computed binding ## Computed binding
A Computed binding names a public no-argument method on the Controller. The method returns the value written to the control. It may read the Controller's fields. It does not write the Model. A computed binding names a public no-argument method on the controller. The method value in FXML is `#name`. A leading `#` is stripped. A value without `#` is used as the method name. The method returns the value written to the control. It may read the controller's fields and the view model. It does not write the model.
```xml ```xml
<ComputedBinding control="greetingLabel" property="text" method="#greeting"/> <ComputedBinding control="greetingLabel" property="text" method="#greeting"/>
@@ -97,91 +185,109 @@ A Computed binding names a public no-argument method on the Controller. The meth
```java ```java
public String greeting() { public String greeting() {
String name = nameField.getText(); String name = nameField.getText();
Object age = viewModel.property("age").getValue();
if (name == null || name.isBlank()) { if (name == null || name.isBlank()) {
return "Hello"; return "Hello (" + age + ")";
} }
return "Hello, " + name.trim(); return "Hello, " + name.trim() + " (" + age + ")";
} }
``` ```
The library calls the method when the binding is applied, and again when a JavaFX property changes on a control in this View or on this View's ViewModel, other than the target. Writing the greeting label does not call `greeting` again. The library calls `greeting` when the binding is applied, and again when a JavaFX property changes on a `Control` in this view, other than the greeting label, or when an attribute property on this view's `ViewModel` changes. Typing the name updates the greeting because the name field changed. Typing the age updates it because the age attribute changed. Writing the greeting label does not call `greeting` again. Nodes that are not controls are not watched. A replacement view model that does not extend `ViewModel` is not watched.
A missing method fails the load with `No computed method "` and the method value. No controller fails with `No controller for`. A method that throws fails with `Could not call`.
An `onAction="#method"` handler is ordinary FXML. `addVisit`, `addTag`, `leaveNote`, `leaveWarning`, and `leaveFatal` are handlers. They are separate from the computed binding.
## Controller ## Controller
Name the class on the root with `fx:controller`. Leave it off and the library supplies a Controller, which is enough for a View that has only Direct bindings. Name the class on the root with `fx:controller`. The container creates it with `getComponent`, so it is the cached singleton. `@FXML` fields are filled during load, before the bindings run.
Implement `HasViewModel` when your code needs the ViewModel. The library calls `setViewModel` after construction and before the bindings run. The desk uses that hand-off for the tag list, the visit list, and Messages. Leave `fx:controller` off and the library supplies a controller, which is enough for a view that has only direct bindings. `NameLine.fxml` in the test is that shape. You cannot obtain the supplied controller from `LoadedView`.
Implement `HasViewModel` when your code needs the view model. The library calls `setViewModel` after construction and before the bindings run, and only when the selected object is a `ViewModel`. The desk stores it and reads `visits`, `tags`, `name`, and `age` from it.
A second view whose document names the same controller class receives that same singleton. FXML then injects the second view's fields into it. The desk is one window, so its controller fields stay on that window. Give a second screen its own controller class when each screen needs its own `@FXML` fields.
## Choosing the ViewModel class
With no annotation, the library builds `ViewModel`. Put `@ViewModelClass` on the controller or the component to name a subclass, or another object that exposes JavaFX properties by name. The class needs a constructor that takes the model instance. If several one-argument constructors match, the most specific parameter type wins. The library calls that constructor. The container does not create the view model.
```java ```java
@Singleton @Singleton
@ViewModelClass(PersonViewModel.class)
public class DeskController implements HasViewModel { public class DeskController implements HasViewModel {
@Override }
public void setViewModel(ViewModel viewModel) { ```
this.viewModel = viewModel;
```java
public class PersonViewModel extends ViewModel {
private final StringProperty title = new SimpleStringProperty(this, "title");
public PersonViewModel(Object model) {
super(model);
title.set("titled-" + ((Person) model).getName());
property("name").addListener((observable, oldValue, newValue) ->
title.set("titled-" + newValue));
}
public StringProperty titleProperty() {
return title;
} }
} }
``` ```
`@FXML` fields are filled during load. A method used as `onAction` is ordinary FXML and is separate from a Computed binding. The desk binds `titleLabel` to path `title`, direction `view`. The label shows `titled-Ada Lovelace`, and it follows the name because the subclass listens to the name property. A binding addresses `titleProperty` by the name `title`.
The first view of an instance fixes the class. A later view that names a different class fails the load with `ViewModel is ` the existing class ` but the View names ` the requested class, and the first view stays as it was. A later view with no annotation reuses the view model. That is what `NameLine.fxml` does.
No matching constructor fails with `No constructor taking the Model on` and the class name.
A component's `@ViewModelClass` applies to that component's model, not to the parent. The library builds that class before the path is read, so a generic view model is not fixed first. An earlier binding in the same list that already resolved a path into that instance would have fixed the class, and a different annotation would then fail the load. The desk does not do that. The person's class is fixed from the controller before the home form's model is set, and `AddressForm` names no view model class, so the address uses `ViewModel`.
## Component ## Component
A Component is a class you reuse for one Model instance. It extends a pane, loads `ClassName.fxml` from its own package through `fx:root`, and is the Controller of that file. It exposes one JavaFX property named `model`. A component is a class the container can create. It extends a pane, loads `ClassName.fxml` from its own package through `fx:root`, and is the controller of that file. It has no `fx:controller`. It exposes one JavaFX property named `model`.
```java `AddressForm` edits the home address. `VisitForm` edits one visit in the repeated list. `NoteForm` edits a note. `VisitRow` edits one table row. Each is `@Singleton`, extends `VBox` or `HBox`, and has `modelProperty()`.
@Singleton
public class AddressForm extends VBox {
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
public ObjectProperty<Object> modelProperty() { The home document binds the form and sets `model` from the path. When the path's last segment is complex, the value assigned is the model instance behind that view model.
return model;
}
}
```
The component document binds its own controls and has no `fx:controller`:
```xml
<fx:root type="javafx.scene.layout.VBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="street" direction="both"/>
</BindingList>
</fx:define>
<TextField fx:id="streetField"/>
</fx:root>
```
A parent document names the class as an element and sets `model` with a Direct binding. When the Path's last segment is complex, the value assigned is the Model instance behind that ViewModel.
```xml ```xml
<DirectBinding control="homeForm" property="model" path="home" direction="both"/> <DirectBinding control="homeForm" property="model" path="home" direction="both"/>
<AddressForm fx:id="homeForm"/> <AddressForm fx:id="homeForm"/>
``` ```
The container creates the Component and injects its services. The component's own bindings run once, when `model` first becomes non-null, and they see that ViewModel. Set the model once. To show another instance, load another Component. The component's own bindings run once, the first time `model` becomes non-null. A later change of `model` does not apply them again. Set the model once. To show another instance, load another component.
A nested Component is the same mechanism: the child document names the next class as an element. `initialize` runs during the component's FXML load, before `model` is set. `AddressForm` uses that moment to copy the injected heading onto its label, which does not need the address. `setViewModel` on a component runs after `model` is set to a non-null model and before that component's bindings. `AddressForm` implements `HasViewModel` and writes `Opened as ` plus the city at that moment. Editing the city afterward leaves the label on the city the form opened with. The sample home opens as `Opened as Sample City`.
The first component of a class in one load is `context.getComponent`, the cached singleton. Each further one in that same load is `context.createComponent`. Repeat and table rows always use `createComponent`, so they are new instances and the cached singleton of that class stays unused by the rows. `getComponent(VisitForm.class)` after the desk loads is still the singleton, and the three forms in the visit pane are other instances.
A later load starts over. Its first component of a class is the cached singleton again. The home `AddressForm` is that singleton and is already in the scene, so loading `Desk.fxml` a second time fails while building the home form: a node cannot be added to a second parent. The test's second view is a document with no component.
A component class needs `ClassName.fxml` beside it. A missing document fails with `No component document`. A missing `modelProperty` method fails with `No model property on`. A method that does not return a `Property` fails with `modelProperty did not return a property`.
## Nested component
A nested component is the same mechanism one level down. The child document names the next class as an element and binds its `model`.
`AddressForm.fxml` binds `noteForm` to path `note` and contains `<NoteForm fx:id="noteForm"/>`. `NoteForm.fxml` binds its text field to path `text`. The sample home note is `Kept at home`. Editing the note field writes `Address.note`.
`VisitForm` does not contain `NoteForm`. A visit added after the desk has loaded is built in a new load. The first component of a class in that load is the cached singleton again, so a `NoteForm` nested in the repeated form would be the instance already showing under the home address. Keeping the note on the home form, which is loaded once, leaves that singleton where it was placed.
A null `note` leaves the nested binding unapplied. `NoteForm` stays empty and the load succeeds. The sample home has a note, so its bindings run.
## Repeat ## Repeat
A `Repeat` element in the same list names a pane, a Component class, and a collection attribute. The library places one new Component in that pane for each Model instance, in list order. Adding or removing an element updates the pane. Those instances are new injected objects, so the cached singleton of the class stays the one used by a single element in the parent document. A `Repeat` element in the same binding list names a pane, a component class, and a collection attribute. The library places one new component in that pane for each model instance, in list order. Adding or removing an element updates the pane. A permutation or a replacement of an element at the same index does not.
```xml ```xml
<Repeat pane="visitPane" component="de.neitzel.fx.example.view.AddressForm" collection="visits"/> <Repeat pane="visitPane" component="de.neitzel.fx.example.view.VisitForm" collection="visits"/>
<VBox fx:id="visitPane"/> <VBox fx:id="visitPane"/>
``` ```
Selection is a separate attribute of the parent Model. Null means nothing is selected. A Direct binding can tie a control's selection to it. The repeated Component does not store the selection. Each child is a new injected instance. Its `model` is the element itself. The desk's Add visit button appends to the list on the view model, which is the list stored on the person:
```xml
<DirectBinding control="visitChoice" property="items" path="visits" direction="view"/>
<DirectBinding control="visitChoice" property="value" path="selectedVisit" direction="both"/>
<ComboBox fx:id="visitChoice"/>
```
The desk's "Add visit" button appends to the list on the ViewModel. That is the list stored back onto the person.
```java ```java
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@@ -190,20 +296,143 @@ private List<Address> visits() {
} }
``` ```
A missing pane, or a node that is not a `Pane`, fails with `No pane "`. A collection that is missing or is not an `ObservableList` fails with `No collection "`. A null list attribute is not an observable list. The desk starts `visits` as an `ArrayList`, which the view model wraps. An unknown component class fails with `No component`.
## Selection
Selection is a separate attribute of the parent model. Null means nothing is selected. The repeated component and the table row do not store it.
The desk uses one `selectedVisit` for the combo box and the table:
```xml
<DirectBinding control="visitChoice" property="items" path="visits" direction="view"/>
<DirectBinding control="visitChoice" property="value" path="selectedVisit" direction="both"/>
<DirectBinding control="visitTable" property="selection" path="selectedVisit" direction="both"/>
```
`Address.toString` is the combo box text, `street, city`. The items binding hits the list shortcut, so the combo box holds the same visit list.
A primary click on a row, including a click in a field, sets the selection to that element. A second click on the row leaves it selected. A click on the blank body clears it. Removing the selected element clears it. The selected row has style class `selected`. The sample desk opens with the first visit selected.
## A list of simple values ## A list of simple values
A list of strings, numbers, or other simple values is one shared `ObservableList`. A Direct binding sets the control property to that list, so an add or remove is visible on both sides. A list of strings, numbers, or other simple values is one shared `ObservableList`. A direct binding sets the control property to that list, so an add or a remove is visible on both sides.
```xml ```xml
<DirectBinding control="tagList" property="items" path="tags" direction="view"/> <DirectBinding control="tagList" property="items" path="tags" direction="view"/>
<ListView fx:id="tagList"/> <ListView fx:id="tagList"/>
``` ```
The desk's "Add tag" button appends to `tags` the same way "Add visit" appends to `visits`. The sample tags are `dns` and `math`. Add tag appends the field's text to the list on the view model and clears the field. Adding to `person.getTags()` after the load grows the list view, because that list is the one the view is watching.
A list of model instances is the same kind of observable list of instances. The visit combo box is that list. It is not a table.
## Table
A table shows a collection of model instances with column headers. Each element keeps one row, a component, for as long as it stays in the collection. The row binds its own fields. Filter and sort use the column attribute. They do not reorder the collection.
```xml
<Table fx:id="visitTable" collection="visits"
row="de.neitzel.fx.example.view.VisitRow" prefWidth="496" prefHeight="160">
<Column header="Street" attribute="street" width="300"/>
<Column header="City" attribute="city"/>
<Column header="Visited" attribute="visitedOn" width="120"/>
</Table>
```
`Table` is a `VBox`. Its default property is `columns`. `VisitRow` is an `fx:root` whose first children are the fields, in column order, and whose last child is Remove. The table sets the width of the first children, one per column. It does not check that child i is bound to column i. Controls after them keep their own size, so Remove is not a column and keeps `USE_COMPUTED_SIZE`. Spacing on the row is repeated between the header cells.
```xml
<fx:root type="javafx.scene.layout.HBox" spacing="8">
<TextField fx:id="streetField"/>
<TextField fx:id="cityField"/>
<TextField fx:id="visitedField"/>
<Button text="Remove" onAction="#remove"/>
</fx:root>
```
`VisitRow.remove` asks the desk controller to remove that address from the visit list. The row and the matching visit form disappear, and a removed selection becomes null.
An empty collection shows the headers and a blank body. Rows scroll vertically inside the table. The header stays put and follows the body's horizontal scroll. Hidden rows are kept and reused when a filter is cleared.
Add and remove update the body. A permutation or an update at the same index is ignored. The table refreshes filter and sort when a text control in a row changes. Other controls in the row do not retrigger it. The field's own direct binding still writes on any property change.
A column attribute must be a simple value: string, boolean, enum, a primitive other than `char` and `void`, a `Number`, a `Date`, a `Calendar`, or a `java.time` temporal. `visitedOn` is a `LocalDate`, so it is a column. A complex attribute fails the load with `Attribute "` name `" is not a simple value`.
Other load failures, all `IllegalArgumentException`:
| Condition | Message |
| --- | --- |
| No column | `A table needs a column` |
| Missing header, blank header, or missing attribute | `A column needs a header and an attribute` |
| The same attribute twice | `Attribute "` name `" is used twice` |
| `width` present and not a positive finite number | `Column "` header `" needs a positive width` |
| Collection missing or not an `ObservableList` | `No collection "` |
| Row class missing | `No row ` |
| Row is not a `Node` | `Row is not a node: ` |
These style classes are on the nodes: `table`, `column-header-row`, `table-scroll`, `table-header-scroll`, `column-header`, `column-cell`, `column-resize`, `column-opener`, `sort-mark`, `selected`, `column-popup`, `column-search`, `column-select-all`, `column-comparison-operator`, `column-comparison-value`, and `filtered` on an opener whose column has an active filter.
### Column width
A column with no `width` shares the table width with the other open columns. A `width` is the starting width, and not below the header text. The text minimum is the header text's layout width, rounded up, plus 8. The desk's Street column opens at 300 and Visited at 120. City names no width, so it receives what remains of the table's 496, which is 76.
A drag on the resize handle changes that column only. A later change of the table's width does not redistribute the columns. A click on the handle does not sort. A width narrower than the header text opens at the header text. If the table's width and preferred width are both 0, the fallback used for equal shares is 80 times the number of columns.
### Sort
The table opens in collection order, with no sort mark.
A primary click on the header cell, aside from the opener button and the resize handle, toggles that column: the first click sorts ascending, the second descending. Sorting a different column drops the other mark and starts at ascending. The mark text is the word `ascending` or `descending`. Null values stay last in both directions. Strings compare ignoring case, and ties keep collection order. Numbers use `doubleValue`. Booleans and enums use `Comparable` when both sides have the same class, so enum order is declaration order. `LocalDate` sorts chronologically.
The collection order is unchanged. Another table on the same list would stay in collection order.
The opener button (style class `column-opener`) opens a popup: `Ascending`, `Descending`, `Clear sort`, `Clear filter`, a search box, `Select all`, the distinct values, a comparison when the attribute allows one, and `Confirm`. `Ascending` sorts and closes the popup. A click outside closes the popup and leaves the sort. `Clear sort` restores collection order and hides the mark.
### Filter
The popup lists the distinct values of that column among rows that pass every other column's filter. This column's own filter is ignored, so unchecked values stay listed. Null is an empty label and sorts first. Enums use `name()`.
All boxes start checked, and confirming with every box checked leaves the filter inactive. A row added later is shown. Unchecking every box and confirming hides every row, including rows added later. Confirming a subset hides the other rows, adds style `filtered` on that column's opener, and does not change the collection. `Clear filter` shows the same row instances again and removes `filtered`.
An edit that makes the row fail the filter hides it and keeps the new value on the entity. A click outside the popup leaves the previous filter. Two active filters both apply.
The role and the active flag are not table columns. A boolean or enum column would offer the checklist and would not offer a comparison.
### Comparison
The comparison is offered for a string, a number, or a date or time. It is not offered for a boolean or an enum.
| Attribute | Operators |
| --- | --- |
| `String` | `Contains`, `Starts with`, `Ends with` |
| number | `Greater than`, `Less than` |
| date or time | `Before`, `After` |
Street and city on the desk are strings. Visited is a `LocalDate`, so its popup offers `Before` and `After`. The desk has no numeric column. A numeric attribute would offer `Greater than` and `Less than`.
If the operator or the value is non-blank, Confirm treats this as a comparison and ignores the checkboxes. The comparison must be valid, or the popup stays open and the previous filter stays. Operator and value are both required. Integral numbers must parse with `Long.parseLong`. Other numbers use `Double.valueOf`. `LocalDate`, `LocalDateTime`, and `Instant` use their `parse`. `Date` and `Calendar` use `Instant.parse`. A value such as `02.01.2020` is rejected for a `LocalDate`. `2020-01-02` is accepted.
String match uses the checklist text, lowercased with `Locale.ROOT`. `Contains` `ER` keeps `Berlin` and `berlin`. The boundary is excluded for numbers and for dates: `Greater than` `5` keeps 6 and drops 5, and `Before` `2020-01-02` keeps the 1st and drops the 2nd. Null does not match a comparison.
Reopening the popup shows the operator and the text that were confirmed. Clearing them and confirming a checklist replaces the comparison.
### Search
The search box hides checklist entries whose text does not contain the query, ignoring case, with `Locale.ROOT`. A `*` is an ordinary character. Checks are unchanged, and rows are unchanged until Confirm. Each opening of the popup clears the search.
`Select all` looks at every box, including hidden ones. Mixed checks show an indeterminate mark. A click selects every box when any is clear, and clears every box when all are selected.
## Messages ## Messages
Every ViewModel has a list of Messages and a latest Message. A Message has a severity (`INFO`, `WARN`, `ERROR`, `FATAL`), a text, and a source. Add one of any severity yourself: Every view model has a list of messages and a latest message. A message has a severity, a text, and a source.
| Severity | What happens |
| --- | --- |
| `INFO` | Stored on the view model. The desk's Leave a note button adds one whose source is `desk` |
| `WARN` | Stored. Leave a warning adds one. It is not written to the log |
| `ERROR` | Stored and written to the SLF4J logger `de.neitzel.fx.view.ViewModel`. Conversion failures use this severity |
| `FATAL` | Stored and written to that same logger. Leave a fatal notice adds one |
```java ```java
viewModel.addMessage(new Message( viewModel.addMessage(new Message(
@@ -212,35 +441,57 @@ viewModel.addMessage(new Message(
"desk")); "desk"));
``` ```
Error and fatal Messages are written to the log. Info and warn stay on the ViewModel. A child Message is carried onto the parent with the source extended, so `street` on an address becomes `home.street` on the person. When the child drops the Message, the parent drops that copy. `getMessages()` is an unmodifiable list, oldest first. `getLatestMessage()` is the most recently added notice. An in-place update of a binding failure does not change which notice is latest, and it is logged again when the severity is error or fatal.
The library does not open a popup or fill a status bar. Bind `latestMessage` when you want the text on a control: `latestMessage` is also a string property, the text of the latest message, or null when there is none. It is not a model attribute. The notice label binds it toward the view:
```xml ```xml
<DirectBinding control="noticeLabel" property="text" path="latestMessage" direction="view"/> <DirectBinding control="noticeLabel" property="text" path="latestMessage" direction="view"/>
``` ```
`latestMessage` is the text of the latest Message. An in-place update of a binding failure refreshes that text and does not count as a new Message. The library does not open a popup or fill a status bar on its own. `MessagePopup.showNewMessages(viewModel)` shows each message added after that call. The desk calls it once, after load. The dialog has no header, its content is a label of the message text, and its button is OK. `Dialog.show()` does not block the caller. Messages already on the view model are not shown. An in-place update does not open another dialog.
Call `MessagePopup.showNewMessages(viewModel)` when a new Message should also appear in a dialog. The example does that after load. An in-place update does not open another dialog. A child message is carried onto the parent. The copy is a different `Message`, with the source extended by the attribute name, so `street` on an address becomes `home.street` on the person. The copy is not written to the log again. When the child drops the message, the parent drops that copy. An in-place text change updates the copy. Setting the complex attribute to null drops the parent's copies and leaves the child's own message. Setting a new child copies the messages that child already has. The same address object on two attributes carries the notice under both attribute names. A cycle does not copy a message back onto a view model that already holds it.
`LoadedView.property(String)` returns the JavaFX property for an attribute name, for `latestMessage`, or for a JavaFX property declared on a ViewModel subclass. The desk calls `showNewMessages` on the person. A carried copy counts as a message added on the parent, so an empty home street opens a dialog as well as filling the notice label.
## Choosing the ViewModel class A list element is not a nested view model. A failure recorded on a visit is carried onto the person when that same address instance is also a complex attribute, such as `selectedVisit`. The visit list itself does not carry it.
With no annotation, the library builds the generic ViewModel. Put `@ViewModelClass` on the Controller or the Component to name a subclass, or another object that exposes JavaFX properties by name. The class needs a constructor that takes the Model instance. The library calls that constructor. The container still does not create the ViewModel. ## What the container creates
```java `ViewLoader.load` takes an `ApplicationContext`. The view model is never created by the container.
@Singleton
@ViewModelClass(TitledViewModel.class)
public class DeskController implements HasViewModel {
}
```
The first View of an instance fixes that class. A later View that names a different class fails to load, and the earlier View stays as it was. A later View with no annotation reuses the ViewModel. | Object | How it is created |
| --- | --- |
| Controller named by `fx:controller` | `getComponent`, once |
| Converter named on a direct binding | `getComponent` |
| First component of a class in a load | `getComponent`, when that instance is free to place |
| Another element of that class in the same load | `createComponent` |
| Repeat children and table rows | `createComponent` |
A subclass may add JavaFX properties under new names. Binding elements address those names the same way they address an attribute. `property` on the subclass is a method such as `titleProperty`. Details of scanning, `@Singleton`, `@Named`, constructor, field, and setter injection, and `@Config` are in the [injection guide](../injection/README.md). The desk's scan root is `DeskApp`.
## Packages ## When a load fails
Every package in the `fx` module is public. Depend on `de.neitzel.lib:fx`. The module depends on `injection`. Application types that the container constructs (`Controller`, `Component`, `Converter`) use `jakarta.inject.Singleton` or `jakarta.inject.Named`, and constructor injection when they need a service. These are the `IllegalArgumentException` messages a document can produce. The view is not shown.
| Message | Cause |
| --- | --- |
| `No controller ` | `fx:controller` names a missing class |
| `No control "` | `fx:id` is missing |
| `No property ` | the control has no such JavaFX property |
| `Not a direction:` | direction is not `view`, `model`, or `both` |
| `No read-write attribute for path "` | the path is not an attribute |
| `Not a binding element:` | a child of `BindingList` is not a direct binding, a computed binding, or a repeat |
| `No converter ` / `Not a converter:` | the converter class is missing or does not implement `Converter` |
| `No controller for ` / `No computed method "` | a computed binding has no controller or no such method |
| `ViewModel is ` … ` but the View names ` | a later view names a different view model class |
| `No constructor taking the Model on ` | the named view model class has no matching constructor |
| `No component document ` | `ClassName.fxml` is missing |
| `No model property on ` | the component has no `modelProperty` |
| `No pane "` / `No collection "` / `No component ` | a repeat is incomplete |
| `A table needs a column` and the other table messages | see [Table](#table) |
| `View root must be a parent` | the FXML root is not a `Parent` |
| `No component found for type:` | the container was asked for a controller, component, or converter it did not scan |
A conversion failure does not fail the load. It records a message, as [Converter](#converter) describes.
+3
View File
@@ -1,3 +1,6 @@
// This file is a design sketch. The user guide is link:README.md[Using fx].
// The container guide is link:../injection/README.md[Injection].
== Komponenten == Komponenten
=== injectfx === injectfx
@@ -17,6 +17,9 @@ import java.util.Map;
@DefaultProperty("elements") @DefaultProperty("elements")
public class BindingList { public class BindingList {
/**
* The binding elements, in document order.
*/
private final List<Object> elements = new ArrayList<>(); private final List<Object> elements = new ArrayList<>();
/** /**
@@ -17,11 +17,33 @@ import java.util.Set;
*/ */
final class ChildMessageMirror implements MessageListener { final class ChildMessageMirror implements MessageListener {
/**
* The parent that receives copies of the child's notices.
*/
private final ViewModel parent; private final ViewModel parent;
/**
* The Attribute that leads to the child. It is prepended to each copied source.
*/
private final String attribute; private final String attribute;
/**
* The child whose notices are carried.
*/
private final ViewModel child; private final ViewModel child;
/**
* Copies already carried, keyed by the identity of the child notice.
*/
private final Map<Message, Message> copies = new IdentityHashMap<>(); 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) { private ChildMessageMirror(ViewModel parent, String attribute, ViewModel child) {
this.parent = parent; this.parent = parent;
this.attribute = attribute; this.attribute = attribute;
@@ -97,6 +119,11 @@ final class ChildMessageMirror implements MessageListener {
parent.noteMessageText(copy); 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) { private void carry(Message message) {
if (copies.containsKey(message) || repeats(message)) { if (copies.containsKey(message) || repeats(message)) {
return; return;
@@ -18,6 +18,11 @@ public class Column {
*/ */
private String attribute; 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. * Sets the header text.
* *
@@ -53,4 +58,34 @@ public class Column {
public String getAttribute() { public String getAttribute() {
return attribute; 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 { final class Components {
/**
* Prevents instantiation.
*/
private Components() { private Components() {
} }
@@ -86,6 +89,13 @@ final class Components {
modelProperty(component).setValue(model); 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) { private static Object load(Object component, ApplicationContext context) {
Class<?> type = component.getClass(); Class<?> type = component.getClass();
String document = type.getSimpleName() + ".fxml"; 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, private static void watchModel(Object component, List<BindingList> lists, Map<String, Object> namespace,
ApplicationContext context) { ApplicationContext context) {
Property<Object> model = modelProperty(component); 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") @SuppressWarnings("unchecked")
private static Property<Object> modelProperty(Object component) { private static Property<Object> modelProperty(Object component) {
try { try {
@@ -10,8 +10,19 @@ package de.neitzel.fx.view;
*/ */
public class ComputedBinding { public class ComputedBinding {
/**
* The {@code fx:id} of the control that receives the method result.
*/
private String control; private String control;
/**
* The control property that receives the method result, such as {@code text}.
*/
private String property; private String property;
/**
* The Controller method, written as {@code #method}.
*/
private String method; private String method;
/** /**
@@ -16,6 +16,9 @@ import java.util.Map;
*/ */
final class ComputedBindingApplier { final class ComputedBindingApplier {
/**
* Prevents instantiation.
*/
private ComputedBindingApplier() { 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) { private static Runnable refresh(Object controller, String methodValue, Property<Object> target) {
boolean[] running = {false}; boolean[] running = {false};
return () -> { 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) { private static void watchControls(Parent root, Property<?> target, Runnable refresh) {
watchNode(root, target, 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) { private static void watchNode(Node node, Property<?> target, Runnable refresh) {
if (node instanceof Control control) { if (node instanceof Control control) {
watchProperties(control, target, refresh); 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") @SuppressWarnings("unchecked")
private static void watchProperties(Object bean, Property<?> target, Runnable refresh) { private static void watchProperties(Object bean, Property<?> target, Runnable refresh) {
ChangeListener<Object> listener = (observable, oldValue, newValue) -> refresh.run(); 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) { private static Object readProperty(Object bean, Method method) {
try { try {
return method.invoke(bean); 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") @SuppressWarnings("unchecked")
private static Property<Object> controlProperty(Object control, String propertyName) { private static Property<Object> controlProperty(Object control, String propertyName) {
String methodName = propertyName + "Property"; 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) { private static Object invoke(Object controller, String methodValue) {
if (controller == null) { if (controller == null) {
throw new IllegalArgumentException("No controller for " + methodValue); 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) { private static String methodName(String methodValue) {
if (methodValue != null && methodValue.startsWith("#")) { if (methodValue != null && methodValue.startsWith("#")) {
return methodValue.substring(1); return methodValue.substring(1);
@@ -12,10 +12,29 @@ package de.neitzel.fx.view;
*/ */
public class DirectBinding { public class DirectBinding {
/**
* The {@code fx:id} of the control.
*/
private String control; private String control;
/**
* The control property, such as {@code text}.
*/
private String property; private String property;
/**
* The Path. One name is an Attribute of this View's ViewModel.
*/
private String path; private String path;
/**
* The direction: {@code view}, {@code model}, or {@code both}.
*/
private String direction; private String direction;
/**
* The Converter class name, or {@code null} when values pass through.
*/
private String converter; private String converter;
/** /**
@@ -14,6 +14,9 @@ import java.util.Map;
*/ */
final class DirectBindingApplier { final class DirectBindingApplier {
/**
* Prevents instantiation.
*/
private DirectBindingApplier() { 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) { private static void alignPlain(Property<?> controlProperty, Property<?> attribute, String direction) {
switch (direction) { switch (direction) {
case "both" -> alignBoth(controlProperty, attribute); case "both" -> alignBoth(controlProperty, attribute);
@@ -84,11 +94,35 @@ final class DirectBindingApplier {
* name as the source, so a parent only carries the extended copy. * name as the source, so a parent only carries the extended copy.
*/ */
private static final class Resolved { private static final class Resolved {
/**
* The Attribute the Path names.
*/
private final Property<?> attribute; private final Property<?> attribute;
/**
* Whether that Attribute is complex.
*/
private final boolean complex; private final boolean complex;
/**
* The ViewModel that owns the Attribute. A conversion failure is recorded here.
*/
private final ViewModel owner; private final ViewModel owner;
/**
* The last segment of the Path, used as the failure source.
*/
private final String name; 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) { private Resolved(Property<?> attribute, boolean complex, ViewModel owner, String name) {
this.attribute = attribute; this.attribute = attribute;
this.complex = complex; this.complex = complex;
@@ -165,6 +199,13 @@ final class DirectBindingApplier {
ViewModels.of(instance, requested); 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) { private static Object instanceAt(Object viewModel, String path) {
if (!(viewModel instanceof ViewModel root)) { if (!(viewModel instanceof ViewModel root)) {
return null; return null;
@@ -179,6 +220,13 @@ final class DirectBindingApplier {
return current; 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) { private static Object readAttribute(Object bean, String name) {
String suffix = Character.toUpperCase(name.charAt(0)) + name.substring(1); String suffix = Character.toUpperCase(name.charAt(0)) + name.substring(1);
for (String prefix : List.of("get", "is")) { for (String prefix : List.of("get", "is")) {
@@ -194,6 +242,13 @@ final class DirectBindingApplier {
throw new IllegalArgumentException("No read-write attribute for path \"" + name + "\""); 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) { private static Property<?> propertyOf(Object target, String name) {
if (target instanceof ViewModel viewModel) { if (target instanceof ViewModel viewModel) {
return viewModel.property(name); return viewModel.property(name);
@@ -201,10 +256,23 @@ final class DirectBindingApplier {
return NamedProperties.get(target, name); 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) { private static IllegalArgumentException missing(String path) {
return new IllegalArgumentException("No read-write attribute for path \"" + 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) { private static Converter converter(ApplicationContext context, String className) {
try { try {
Class<?> type = Class.forName(className); 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, private static void pushControl(Property<Object> control, Property<Object> attribute, Converter converter,
ViewModel owner, String source, Link link) { ViewModel owner, String source, Link link) {
try { 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, private static void pushAttribute(Property<Object> control, Property<Object> attribute, Converter converter,
ViewModel owner, String source, Link link) { ViewModel owner, String source, Link link) {
try { 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) { private static String failureText(RuntimeException failure) {
String text = failure.getMessage(); String text = failure.getMessage();
if (text == null || text.isEmpty()) { if (text == null || text.isEmpty()) {
@@ -289,9 +383,20 @@ final class DirectBindingApplier {
* Guards one converted binding against hearing its own writes. * Guards one converted binding against hearing its own writes.
*/ */
private static final class Link { private static final class Link {
/**
* Whether this binding is currently writing one side, so the other listener stays quiet.
*/
private boolean applying; 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) { private static Property<?> controlProperty(Object control, String propertyName) {
String methodName = propertyName + "Property"; String methodName = propertyName + "Property";
try { 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") @SuppressWarnings("unchecked")
private static void alignBothComplex(Property<?> controlProperty, Property<?> attribute) { private static void alignBothComplex(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty; 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") @SuppressWarnings("unchecked")
private static void alignTowardViewComplex(Property<?> controlProperty, Property<?> attribute) { private static void alignTowardViewComplex(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty; Property<Object> control = (Property<Object>) controlProperty;
@@ -336,6 +456,12 @@ final class DirectBindingApplier {
source.addListener((observable, oldValue, newValue) -> control.setValue(modelBehind(newValue))); 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") @SuppressWarnings("unchecked")
private static void alignTowardModelComplex(Property<?> controlProperty, Property<?> attribute) { private static void alignTowardModelComplex(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty; 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) { private static Object modelBehind(Object value) {
if (value instanceof ViewModel viewModel) { if (value instanceof ViewModel viewModel) {
return viewModel.model(); return viewModel.model();
@@ -356,16 +488,34 @@ final class DirectBindingApplier {
return null; 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") @SuppressWarnings("unchecked")
private static void assignList(Property<?> controlProperty, ObservableList<?> values) { private static void assignList(Property<?> controlProperty, ObservableList<?> values) {
((Property<Object>) controlProperty).setValue(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") @SuppressWarnings("unchecked")
private static void alignBoth(Property<?> controlProperty, Property<?> attribute) { private static void alignBoth(Property<?> controlProperty, Property<?> attribute) {
((Property<Object>) controlProperty).bindBidirectional((Property<Object>) 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") @SuppressWarnings("unchecked")
private static void alignTowardView(Property<?> controlProperty, Property<?> attribute) { private static void alignTowardView(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty; Property<Object> control = (Property<Object>) controlProperty;
@@ -374,6 +524,12 @@ final class DirectBindingApplier {
source.addListener((observable, oldValue, newValue) -> control.setValue(newValue)); 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") @SuppressWarnings("unchecked")
private static void alignTowardModel(Property<?> controlProperty, Property<?> attribute) { private static void alignTowardModel(Property<?> controlProperty, Property<?> attribute) {
Property<Object> control = (Property<Object>) controlProperty; Property<Object> control = (Property<Object>) controlProperty;
@@ -18,8 +18,14 @@ import java.net.URL;
*/ */
final class FxmlControllers { final class FxmlControllers {
/**
* The FXML namespace used by {@code fx:controller}.
*/
private static final String FXML_NAMESPACE = "http://javafx.com/fxml"; private static final String FXML_NAMESPACE = "http://javafx.com/fxml";
/**
* Prevents instantiation.
*/
private FxmlControllers() { 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) { private static String controllerName(URL fxml) {
try (InputStream input = fxml.openStream()) { try (InputStream input = fxml.openStream()) {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
@@ -17,11 +17,24 @@ import java.util.Set;
*/ */
final class LoadSession { 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<>(); 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<>(); private final List<BindingList> bindingLists = new ArrayList<>();
/**
* Component classes already built during the outermost load.
*/
private final Set<Class<?>> built = new HashSet<>(); private final Set<Class<?>> built = new HashSet<>();
/**
* Prevents instantiation from outside this class.
*/
private LoadSession() { private LoadSession() {
} }
@@ -8,7 +8,14 @@ import javafx.scene.Parent;
*/ */
public final class LoadedView { public final class LoadedView {
/**
* The scene-graph root of the loaded View.
*/
private final Parent root; private final Parent root;
/**
* The ViewModel the View is bound to, or a replacement that exposes JavaFX properties by name.
*/
private final Object viewModel; private final Object viewModel;
/** /**
@@ -19,9 +19,24 @@ public final class Message {
FATAL FATAL
} }
/**
* How serious the notice is.
*/
private final Severity severity; private final Severity severity;
/**
* The notice text. A binding failure updates this in place.
*/
private String text; private String text;
/**
* Where the notice came from, such as a binding Path.
*/
private final String source; private final String source;
/**
* The child notice this one copies, or {@code null} when this notice was added directly.
*/
private Message origin; private Message origin;
/** /**
@@ -12,6 +12,9 @@ import javafx.scene.control.Label;
*/ */
public final class MessagePopup { public final class MessagePopup {
/**
* Prevents instantiation.
*/
private MessagePopup() { private MessagePopup() {
} }
@@ -12,6 +12,9 @@ import java.lang.reflect.Method;
*/ */
final class NamedProperties { final class NamedProperties {
/**
* Prevents instantiation.
*/
private NamedProperties() { private NamedProperties() {
} }
@@ -9,8 +9,19 @@ package de.neitzel.fx.view;
*/ */
public class Repeat { public class Repeat {
/**
* The {@code fx:id} of the pane that holds the Component children.
*/
private String pane; private String pane;
/**
* The Component class name. Each element gets a new instance of that class.
*/
private String component; private String component;
/**
* The collection Attribute. The list holds the Model instances.
*/
private String collection; private String collection;
/** /**
@@ -14,6 +14,9 @@ import java.util.Map;
*/ */
final class RepeatApplier { final class RepeatApplier {
/**
* Prevents instantiation.
*/
private RepeatApplier() { private RepeatApplier() {
} }
@@ -47,6 +50,13 @@ final class RepeatApplier {
listen(pane, type, context, values); 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) { private static Property<?> collection(Object viewModel, String name) {
if (viewModel instanceof ViewModel model) { if (viewModel instanceof ViewModel model) {
return model.property(name); return model.property(name);
@@ -54,6 +64,17 @@ final class RepeatApplier {
return NamedProperties.get(viewModel, name); 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") @SuppressWarnings("unchecked")
private static void listen(Pane pane, Class<?> type, ApplicationContext context, ObservableList<?> values) { private static void listen(Pane pane, Class<?> type, ApplicationContext context, ObservableList<?> values) {
ObservableList<Object> elements = (ObservableList<Object>) 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) { private static Node child(Class<?> type, ApplicationContext context, Object element) {
Object component = Components.fresh(type, context); Object component = Components.fresh(type, context);
Components.setModel(component, element); Components.setModel(component, element);
return (Node) component; 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) { private static Class<?> componentType(String className) {
try { try {
return Class.forName(className); return Class.forName(className);
@@ -8,6 +8,9 @@ package de.neitzel.fx.view;
*/ */
final class SuppliedController implements HasViewModel { final class SuppliedController implements HasViewModel {
/**
* The ViewModel handed over before the bindings run.
*/
private ViewModel viewModel; private ViewModel viewModel;
/** /**
+117 -11
View File
@@ -47,8 +47,10 @@ import java.util.Set;
* A collection shown with one Component for each element. * A collection shown with one Component for each element.
* <p> * <p>
* The developer places the Table in the View and names the collection Attribute, the Row * 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. * 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") @DefaultProperty("columns")
public class Table extends VBox { 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. * One Filter per Column. A null entry means that Column has no Filter yet.
*/ */
private final List<ValueFilter> valueFilters = new ArrayList<>(); 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; private int popupIndex = -1;
/** /**
@@ -410,6 +415,9 @@ public class Table extends VBox {
if (!seen.add(column.getAttribute())) { if (!seen.add(column.getAttribute())) {
throw new IllegalArgumentException("Attribute \"" + column.getAttribute() + "\" is used twice"); 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() { private void ensureWidths() {
if (columnWidths != null && columnWidths.length == columns.size()) { if (columnWidths != null && columnWidths.length == columns.size()) {
return; 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(); double width = getWidth() > 0 ? getWidth() : getPrefWidth();
if (width <= 0) { if (width <= 0) {
width = columns.size() * 80.0; return columns.size() * 80.0;
} }
columnWidths = new double[columns.size()]; return width;
java.util.Arrays.fill(columnWidths, width / columns.size());
} }
/** /**
* Applies the stored Column widths to the header and the fields. * 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() { private void applyWidths() {
if (columns.isEmpty() || columnWidths == null) { if (columns.isEmpty() || columnWidths == null) {
return; return;
} }
double sum = 0; double columnSum = 0;
for (double width : columnWidths) { for (double width : columnWidths) {
sum += width; columnSum += width;
} }
header.setPrefWidth(sum); double spacing = rowSpacing();
header.setMinWidth(sum); 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++) { for (int index = 0; index < header.getChildren().size(); index++) {
fixWidth((Region) header.getChildren().get(index), columnWidths[index]); fixWidth((Region) header.getChildren().get(index), columnWidths[index]);
} }
for (Node rowNode : body.getChildren()) { for (Node rowNode : body.getChildren()) {
double rowWidth = rowNode instanceof HBox box ? widthOf(box, columnSum) : headerWidth;
if (rowNode instanceof Region region) { if (rowNode instanceof Region region) {
region.setPrefWidth(sum); region.setPrefWidth(rowWidth);
region.setMinWidth(sum); region.setMinWidth(rowWidth);
} }
if (rowNode instanceof Parent parent) { if (rowNode instanceof Parent parent) {
int index = 0; 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. * Fixes one node's width.
* *
@@ -77,17 +77,59 @@ import java.util.function.Consumer;
*/ */
public class ViewModel { public class ViewModel {
/**
* Receives error and fatal notices. Info and warn are not logged.
*/
private static final Logger LOGGER = LoggerFactory.getLogger(ViewModel.class); private static final Logger LOGGER = LoggerFactory.getLogger(ViewModel.class);
/**
* The Model instance this ViewModel was built for.
*/
private final Object model; private final Object model;
/**
* JavaFX properties keyed by Attribute name.
*/
private final Map<String, Property<?>> properties = new HashMap<>(); private final Map<String, Property<?>> properties = new HashMap<>();
/**
* Attribute names whose value is a nested ViewModel.
*/
private final Set<String> complexAttributes = new HashSet<>(); private final Set<String> complexAttributes = new HashSet<>();
/**
* Notices on this ViewModel, oldest first.
*/
private final List<Message> messages = new ArrayList<>(); 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<>(); 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<>(); 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<>(); private final Map<String, ChildMessageMirror> childMirrors = new HashMap<>();
/**
* The text of {@link #latestMessage}, bound as {@code latestMessage}.
*/
private final StringProperty latestMessageText = new SimpleStringProperty(); private final StringProperty latestMessageText = new SimpleStringProperty();
/**
* Callbacks registered by {@link MessagePopup#showNewMessages} and similar callers.
*/
private final List<Consumer<Message>> messageAdded = new ArrayList<>(); private final List<Consumer<Message>> messageAdded = new ArrayList<>();
/**
* The notice most recently added, or {@code null} when there is none.
*/
private Message latestMessage; private Message latestMessage;
/** /**
@@ -191,6 +233,12 @@ public class ViewModel {
return NamedProperties.get(this, name); 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) { private static List<PropertyDescriptor> descriptors(Class<?> type) {
try { try {
BeanInfo info = Introspector.getBeanInfo(type, Object.class); 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) { private static boolean isComplex(Class<?> type) {
if (simpleValue(type)) { if (simpleValue(type)) {
return false; return false;
@@ -214,6 +268,15 @@ public class ViewModel {
return false; 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) { private static boolean simpleValue(Class<?> type) {
if (type.isPrimitive() || type.isEnum() || type.isArray() if (type.isPrimitive() || type.isEnum() || type.isArray()
|| type == String.class || type == Boolean.class) { || type == String.class || type == Boolean.class) {
@@ -232,6 +295,15 @@ public class ViewModel {
return packaged != null && packaged.getName().startsWith("java.time"); 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) { private static Property<?> createProperty(Class<?> type, Object value) {
if (type == String.class) { if (type == String.class) {
return new SimpleStringProperty((String) value); return new SimpleStringProperty((String) value);
@@ -254,6 +326,12 @@ public class ViewModel {
return new SimpleObjectProperty<>(value); 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) { private static IntegerProperty integerProperty(Object value) {
SimpleIntegerProperty property = new SimpleIntegerProperty(); SimpleIntegerProperty property = new SimpleIntegerProperty();
if (value != null) { if (value != null) {
@@ -262,6 +340,12 @@ public class ViewModel {
return property; 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) { private static LongProperty longProperty(Object value) {
SimpleLongProperty property = new SimpleLongProperty(); SimpleLongProperty property = new SimpleLongProperty();
if (value != null) { if (value != null) {
@@ -270,6 +354,12 @@ public class ViewModel {
return property; 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) { private static FloatProperty floatProperty(Object value) {
SimpleFloatProperty property = new SimpleFloatProperty(); SimpleFloatProperty property = new SimpleFloatProperty();
if (value != null) { if (value != null) {
@@ -278,6 +368,12 @@ public class ViewModel {
return property; 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) { private static DoubleProperty doubleProperty(Object value) {
SimpleDoubleProperty property = new SimpleDoubleProperty(); SimpleDoubleProperty property = new SimpleDoubleProperty();
if (value != null) { if (value != null) {
@@ -286,6 +382,12 @@ public class ViewModel {
return property; 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) { private static BooleanProperty booleanProperty(Object value) {
SimpleBooleanProperty property = new SimpleBooleanProperty(); SimpleBooleanProperty property = new SimpleBooleanProperty();
if (value != null) { if (value != null) {
@@ -294,6 +396,13 @@ public class ViewModel {
return property; 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) { private Object read(Method getter, String name) {
try { try {
return getter.invoke(model); 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) { private void watch(Property<?> property, Method setter, String name) {
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue)); property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
} }
@@ -314,14 +430,35 @@ public class ViewModel {
*/ */
private static final class LazyComplex extends SimpleObjectProperty<Object> { private static final class LazyComplex extends SimpleObjectProperty<Object> {
/**
* The Model instance, kept until the nested ViewModel is built.
*/
private final Object current; private final Object current;
/**
* Whether {@link #get()} has already built the nested ViewModel.
*/
private boolean realized; private boolean realized;
/**
* Whether the change listener is still being attached. Reads during that time are ignored.
*/
private boolean attaching = true; 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) { private LazyComplex(Object current) {
this.current = 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 @Override
public Object get() { public Object get() {
if (!attaching && !realized) { if (!attaching && !realized) {
@@ -331,15 +468,31 @@ public class ViewModel {
return super.get(); return super.get();
} }
/**
* Allows the next {@link #get()} to build the nested ViewModel.
*/
private void attached() { private void attached() {
attaching = false; 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) { private boolean pending(Object instance) {
return !realized && current == 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) { private void watchComplex(Property<?> property, Method setter, String name) {
property.addListener((observable, oldValue, newValue) -> { property.addListener((observable, oldValue, newValue) -> {
if (oldValue != newValue) { if (oldValue != newValue) {
@@ -360,6 +513,10 @@ public class ViewModel {
* @param name the Attribute that leads to the child * @param name the Attribute that leads to the child
* @param child the child ViewModel * @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. * Realizes every other Complex attribute that holds the same Model instance.
* <p> * <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. * 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) { private void write(Method setter, String name, Object value) {
try { try {
setter.invoke(model, value); 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) { private static boolean isSimpleValueCollection(Method getter) {
if (!Collection.class.isAssignableFrom(getter.getReturnType())) { if (!Collection.class.isAssignableFrom(getter.getReturnType())) {
return false; return false;
@@ -615,6 +781,12 @@ public class ViewModel {
return element != null && isSimpleValue(element); 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) { private static boolean isModelCollection(Method getter) {
if (!Collection.class.isAssignableFrom(getter.getReturnType())) { if (!Collection.class.isAssignableFrom(getter.getReturnType())) {
return false; return false;
@@ -623,6 +795,12 @@ public class ViewModel {
return element != null && isComplex(element); 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) { private static Class<?> elementType(Type type) {
if (!(type instanceof ParameterizedType parameterized) || parameterized.getActualTypeArguments().length != 1) { if (!(type instanceof ParameterizedType parameterized) || parameterized.getActualTypeArguments().length != 1) {
return null; return null;
@@ -630,6 +808,12 @@ public class ViewModel {
return classOf(parameterized.getActualTypeArguments()[0]); 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) { private static Class<?> classOf(Type type) {
if (type instanceof Class<?> raw) { if (type instanceof Class<?> raw) {
return raw; return raw;
@@ -643,6 +827,12 @@ public class ViewModel {
return null; 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) { private static boolean isSimpleValue(Class<?> type) {
if (type == String.class || type == Boolean.class || type == boolean.class || type.isEnum()) { if (type == String.class || type == Boolean.class || type == boolean.class || type.isEnum()) {
return true; return true;
@@ -659,6 +849,14 @@ public class ViewModel {
return TemporalAccessor.class.isAssignableFrom(type) || TemporalAmount.class.isAssignableFrom(type); 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") @SuppressWarnings("unchecked")
private Object shareSimpleList(Method setter, String name, Object current) { private Object shareSimpleList(Method setter, String name, Object current) {
if (!(current instanceof Collection<?> collection)) { if (!(current instanceof Collection<?> collection)) {
@@ -12,8 +12,14 @@ import java.util.Map;
*/ */
final class ViewModels { final class ViewModels {
/**
* ViewModels already built, keyed by Model identity.
*/
private static final Map<Object, Object> BY_INSTANCE = new IdentityHashMap<>(); private static final Map<Object, Object> BY_INSTANCE = new IdentityHashMap<>();
/**
* Prevents instantiation.
*/
private ViewModels() { private ViewModels() {
} }
@@ -75,6 +81,16 @@ final class ViewModels {
return annotation.value(); 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) { private static Object construct(Object model, Class<?> viewModelClass) {
Constructor<?> constructor = constructor(model, viewModelClass); Constructor<?> constructor = constructor(model, viewModelClass);
constructor.setAccessible(true); 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) { private static Constructor<?> constructor(Object model, Class<?> viewModelClass) {
Constructor<?> chosen = null; Constructor<?> chosen = null;
for (Constructor<?> candidate : viewModelClass.getDeclaredConstructors()) { 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.assertSame;
import static org.junit.jupiter.api.Assertions.fail; 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 { class CarryChildMessageTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll @BeforeAll
static void startJavaFx() throws InterruptedException { static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1); 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 @Test
void aStreetNoticeOnTheAddressAppearsOnThePersonAsAddressStreet() throws Exception { void aStreetNoticeOnTheAddressAppearsOnThePersonAsAddressStreet() throws Exception {
onFx(() -> { onFx(() -> {
@@ -64,6 +73,9 @@ class CarryChildMessageTest {
}); });
} }
/**
* A nested street notice reads as person address street.
*/
@Test @Test
void aNestedStreetNoticeReadsAsPersonAddressStreet() throws Exception { void aNestedStreetNoticeReadsAsPersonAddressStreet() throws Exception {
onFx(() -> { onFx(() -> {
@@ -90,6 +102,9 @@ class CarryChildMessageTest {
}); });
} }
/**
* A bad street is owned by the address and carried as address street.
*/
@Test @Test
void aBadStreetIsOwnedByTheAddressAndCarriedAsAddressStreet() throws Exception { void aBadStreetIsOwnedByTheAddressAndCarriedAsAddressStreet() throws Exception {
onFx(() -> { onFx(() -> {
@@ -120,6 +135,9 @@ class CarryChildMessageTest {
}); });
} }
/**
* A fixed street drops the notice from the address and the person.
*/
@Test @Test
void aFixedStreetDropsTheNoticeFromTheAddressAndThePerson() throws Exception { void aFixedStreetDropsTheNoticeFromTheAddressAndThePerson() throws Exception {
onFx(() -> { onFx(() -> {
@@ -143,6 +161,9 @@ class CarryChildMessageTest {
}); });
} }
/**
* Another bad street updates the carried notice in place.
*/
@Test @Test
void anotherBadStreetUpdatesTheCarriedNoticeInPlace() throws Exception { void anotherBadStreetUpdatesTheCarriedNoticeInPlace() throws Exception {
onFx(() -> { onFx(() -> {
@@ -186,6 +207,9 @@ class CarryChildMessageTest {
}); });
} }
/**
* Clearing the address drops the carried notice and a new address brings its own.
*/
@Test @Test
void clearingTheAddressDropsTheCarriedNoticeAndANewAddressBringsItsOwn() throws Exception { void clearingTheAddressDropsTheCarriedNoticeAndANewAddressBringsItsOwn() throws Exception {
onFx(() -> { onFx(() -> {
@@ -225,6 +249,9 @@ class CarryChildMessageTest {
}); });
} }
/**
* Fixing the street leaves the persons own notice.
*/
@Test @Test
void fixingTheStreetLeavesThePersonsOwnNotice() throws Exception { void fixingTheStreetLeavesThePersonsOwnNotice() throws Exception {
onFx(() -> { onFx(() -> {
@@ -265,6 +292,9 @@ class CarryChildMessageTest {
}); });
} }
/**
* A fixed nested street drops the notice up the path.
*/
@Test @Test
void aFixedNestedStreetDropsTheNoticeUpThePath() throws Exception { void aFixedNestedStreetDropsTheNoticeUpThePath() throws Exception {
onFx(() -> { onFx(() -> {
@@ -293,6 +323,9 @@ class CarryChildMessageTest {
}); });
} }
/**
* A shared address carries the street notice on home and on work.
*/
@Test @Test
void aSharedAddressCarriesTheStreetNoticeOnHomeAndOnWork() throws Exception { void aSharedAddressCarriesTheStreetNoticeOnHomeAndOnWork() throws Exception {
onFx(() -> { 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 { private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) { if (Platform.isFxApplicationThread()) {
action.run(); action.run();
@@ -349,8 +387,16 @@ class CarryChildMessageTest {
} }
} }
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface @FunctionalInterface
private interface FxAction { private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception; 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.assertTrue;
import static org.junit.jupiter.api.Assertions.fail; 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 { class ChooseControllerAndViewModelTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll @BeforeAll
static void startJavaFx() throws InterruptedException { static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1); CountDownLatch started = new CountDownLatch(1);
@@ -36,6 +42,9 @@ class ChooseControllerAndViewModelTest {
} }
} }
/**
* Label shows the name recorded when the view model was handed over.
*/
@Test @Test
void labelShowsTheNameRecordedWhenTheViewModelWasHandedOver() throws Exception { void labelShowsTheNameRecordedWhenTheViewModelWasHandedOver() throws Exception {
onFx(() -> { onFx(() -> {
@@ -49,6 +58,9 @@ class ChooseControllerAndViewModelTest {
}); });
} }
/**
* Label shows the greeting injected into the controller.
*/
@Test @Test
void labelShowsTheGreetingInjectedIntoTheController() throws Exception { void labelShowsTheGreetingInjectedIntoTheController() throws Exception {
onFx(() -> { onFx(() -> {
@@ -62,6 +74,9 @@ class ChooseControllerAndViewModelTest {
}); });
} }
/**
* Label shows the title from the named view model.
*/
@Test @Test
void labelShowsTheTitleFromTheNamedViewModel() throws Exception { void labelShowsTheTitleFromTheNamedViewModel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -81,6 +96,9 @@ class ChooseControllerAndViewModelTest {
}); });
} }
/**
* Label shows the badge from a replacement view model.
*/
@Test @Test
void labelShowsTheBadgeFromAReplacementViewModel() throws Exception { void labelShowsTheBadgeFromAReplacementViewModel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -100,6 +118,9 @@ class ChooseControllerAndViewModelTest {
}); });
} }
/**
* Component label shows the caption from the named view model.
*/
@Test @Test
void componentLabelShowsTheCaptionFromTheNamedViewModel() throws Exception { void componentLabelShowsTheCaptionFromTheNamedViewModel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -114,6 +135,9 @@ class ChooseControllerAndViewModelTest {
}); });
} }
/**
* A later view that names a different class fails and leaves the first view.
*/
@Test @Test
void aLaterViewThatNamesADifferentClassFailsAndLeavesTheFirstView() throws Exception { void aLaterViewThatNamesADifferentClassFailsAndLeavesTheFirstView() throws Exception {
onFx(() -> { onFx(() -> {
@@ -134,6 +158,9 @@ class ChooseControllerAndViewModelTest {
}); });
} }
/**
* A later view with no annotation reuses the view model.
*/
@Test @Test
void aLaterViewWithNoAnnotationReusesTheViewModel() throws Exception { void aLaterViewWithNoAnnotationReusesTheViewModel() throws Exception {
onFx(() -> { 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 { private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) { if (Platform.isFxApplicationThread()) {
action.run(); action.run();
@@ -186,8 +218,16 @@ class ChooseControllerAndViewModelTest {
} }
} }
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface @FunctionalInterface
private interface FxAction { private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception; 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.assertNotNull;
import static org.junit.jupiter.api.Assertions.fail; 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 { class ComputeAValueTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll @BeforeAll
static void startJavaFx() throws InterruptedException { static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1); CountDownLatch started = new CountDownLatch(1);
@@ -33,6 +39,9 @@ class ComputeAValueTest {
} }
} }
/**
* Computed binding shows the method result and leaves the model unchanged.
*/
@Test @Test
void computedBindingShowsTheMethodResultAndLeavesTheModelUnchanged() throws Exception { void computedBindingShowsTheMethodResultAndLeavesTheModelUnchanged() throws Exception {
onFx(() -> { onFx(() -> {
@@ -49,6 +58,9 @@ class ComputeAValueTest {
}); });
} }
/**
* Typing in another text field updates the computed label.
*/
@Test @Test
void typingInAnotherTextFieldUpdatesTheComputedLabel() throws Exception { void typingInAnotherTextFieldUpdatesTheComputedLabel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -65,6 +77,9 @@ class ComputeAValueTest {
}); });
} }
/**
* Setting a view model property updates the computed label.
*/
@Test @Test
void settingAViewModelPropertyUpdatesTheComputedLabel() throws Exception { void settingAViewModelPropertyUpdatesTheComputedLabel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -82,6 +97,9 @@ class ComputeAValueTest {
}); });
} }
/**
* Changing the computed label does not run the method again.
*/
@Test @Test
void changingTheComputedLabelDoesNotRunTheMethodAgain() throws Exception { void changingTheComputedLabelDoesNotRunTheMethodAgain() throws Exception {
onFx(() -> { 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 { private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) { if (Platform.isFxApplicationThread()) {
action.run(); action.run();
@@ -128,8 +151,16 @@ class ComputeAValueTest {
} }
} }
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface @FunctionalInterface
private interface FxAction { private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception; 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.assertTrue;
import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.fail;
/**
* A converter failure keeps the previous value and records one error message.
*/
class ConvertAndRecordFailureTest { class ConvertAndRecordFailureTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll @BeforeAll
static void startJavaFx() throws InterruptedException { static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1); CountDownLatch started = new CountDownLatch(1);
@@ -40,6 +46,9 @@ class ConvertAndRecordFailureTest {
} }
} }
/**
* Injected converter prefixes the age text.
*/
@Test @Test
void injectedConverterPrefixesTheAgeText() throws Exception { void injectedConverterPrefixesTheAgeText() throws Exception {
onFx(() -> { onFx(() -> {
@@ -54,6 +63,9 @@ class ConvertAndRecordFailureTest {
}); });
} }
/**
* Typing a prefixed age updates the model.
*/
@Test @Test
void typingAPrefixedAgeUpdatesTheModel() throws Exception { void typingAPrefixedAgeUpdatesTheModel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -69,6 +81,9 @@ class ConvertAndRecordFailureTest {
}); });
} }
/**
* Toward view loads when the reverse method is absent.
*/
@Test @Test
void towardViewLoadsWhenTheReverseMethodIsAbsent() throws Exception { void towardViewLoadsWhenTheReverseMethodIsAbsent() throws Exception {
onFx(() -> { onFx(() -> {
@@ -85,6 +100,9 @@ class ConvertAndRecordFailureTest {
}); });
} }
/**
* Toward view converter follows a later age.
*/
@Test @Test
void towardViewConverterFollowsALaterAge() throws Exception { void towardViewConverterFollowsALaterAge() throws Exception {
onFx(() -> { onFx(() -> {
@@ -103,6 +121,9 @@ class ConvertAndRecordFailureTest {
}); });
} }
/**
* Toward model converts typed text without following the attribute.
*/
@Test @Test
void towardModelConvertsTypedTextWithoutFollowingTheAttribute() throws Exception { void towardModelConvertsTypedTextWithoutFollowingTheAttribute() throws Exception {
onFx(() -> { onFx(() -> {
@@ -123,6 +144,9 @@ class ConvertAndRecordFailureTest {
}); });
} }
/**
* Bad age text keeps the previous int and one error message.
*/
@Test @Test
void badAgeTextKeepsThePreviousIntAndOneErrorMessage() throws Exception { void badAgeTextKeepsThePreviousIntAndOneErrorMessage() throws Exception {
onFx(() -> { onFx(() -> {
@@ -146,6 +170,9 @@ class ConvertAndRecordFailureTest {
}); });
} }
/**
* Another bad age updates the same message.
*/
@Test @Test
void anotherBadAgeUpdatesTheSameMessage() throws Exception { void anotherBadAgeUpdatesTheSameMessage() throws Exception {
onFx(() -> { onFx(() -> {
@@ -168,6 +195,9 @@ class ConvertAndRecordFailureTest {
}); });
} }
/**
* A valid age removes the failure message.
*/
@Test @Test
void aValidAgeRemovesTheFailureMessage() throws Exception { void aValidAgeRemovesTheFailureMessage() throws Exception {
onFx(() -> { onFx(() -> {
@@ -187,6 +217,9 @@ class ConvertAndRecordFailureTest {
}); });
} }
/**
* Developer info stays latest and is not logged.
*/
@Test @Test
void developerInfoStaysLatestAndIsNotLogged() throws Exception { void developerInfoStaysLatestAndIsNotLogged() throws Exception {
onFx(() -> { onFx(() -> {
@@ -212,6 +245,9 @@ class ConvertAndRecordFailureTest {
}); });
} }
/**
* Error and fatal messages are written to the log.
*/
@Test @Test
void errorAndFatalMessagesAreWrittenToTheLog() throws Exception { void errorAndFatalMessagesAreWrittenToTheLog() throws Exception {
onFx(() -> { 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 { private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) { if (Platform.isFxApplicationThread()) {
action.run(); 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 { private static String captureErr(FxAction action) throws Exception {
PrintStream previous = System.err; PrintStream previous = System.err;
ByteArrayOutputStream buffer = new ByteArrayOutputStream(); ByteArrayOutputStream buffer = new ByteArrayOutputStream();
@@ -285,8 +332,16 @@ class ConvertAndRecordFailureTest {
} }
} }
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface @FunctionalInterface
private interface FxAction { private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception; 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.assertSame;
import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.fail;
/**
* A component edits one model instance, and a nested component edits the next.
*/
class EditAddressInAComponentTest { class EditAddressInAComponentTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll @BeforeAll
static void startJavaFx() throws InterruptedException { static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1); CountDownLatch started = new CountDownLatch(1);
@@ -38,6 +44,9 @@ class EditAddressInAComponentTest {
} }
} }
/**
* Editing the street updates that address.
*/
@Test @Test
void editingTheStreetUpdatesThatAddress() throws Exception { void editingTheStreetUpdatesThatAddress() throws Exception {
onFx(() -> { onFx(() -> {
@@ -58,6 +67,9 @@ class EditAddressInAComponentTest {
}); });
} }
/**
* Null address leaves the model unset and keeps the street text.
*/
@Test @Test
void nullAddressLeavesTheModelUnsetAndKeepsTheStreetText() throws Exception { void nullAddressLeavesTheModelUnsetAndKeepsTheStreetText() throws Exception {
onFx(() -> { onFx(() -> {
@@ -75,6 +87,9 @@ class EditAddressInAComponentTest {
}); });
} }
/**
* Street label shows the injected prefix.
*/
@Test @Test
void streetLabelShowsTheInjectedPrefix() throws Exception { void streetLabelShowsTheInjectedPrefix() throws Exception {
onFx(() -> { onFx(() -> {
@@ -90,6 +105,9 @@ class EditAddressInAComponentTest {
}); });
} }
/**
* Component receives the address view model before its bindings run.
*/
@Test @Test
void componentReceivesTheAddressViewModelBeforeItsBindingsRun() throws Exception { void componentReceivesTheAddressViewModelBeforeItsBindingsRun() throws Exception {
onFx(() -> { onFx(() -> {
@@ -108,6 +126,9 @@ class EditAddressInAComponentTest {
}); });
} }
/**
* Nested component shows the inner street.
*/
@Test @Test
void nestedComponentShowsTheInnerStreet() throws Exception { void nestedComponentShowsTheInnerStreet() throws Exception {
onFx(() -> { 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 { private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) { if (Platform.isFxApplicationThread()) {
action.run(); action.run();
@@ -155,8 +181,16 @@ class EditAddressInAComponentTest {
} }
} }
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface @FunctionalInterface
private interface FxAction { private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception; 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.assertSame;
import static org.junit.jupiter.api.Assertions.fail; 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 { class ReachNestedAttributeTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll @BeforeAll
static void startJavaFx() throws InterruptedException { static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1); CountDownLatch started = new CountDownLatch(1);
@@ -46,6 +52,9 @@ class ReachNestedAttributeTest {
} }
} }
/**
* Complex attribute holds the view model for the nested address.
*/
@Test @Test
void complexAttributeHoldsTheViewModelForTheNestedAddress() throws Exception { void complexAttributeHoldsTheViewModelForTheNestedAddress() throws Exception {
onFx(() -> { onFx(() -> {
@@ -61,6 +70,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* Both ways text field reads and writes the nested street.
*/
@Test @Test
void bothWaysTextFieldReadsAndWritesTheNestedStreet() throws Exception { void bothWaysTextFieldReadsAndWritesTheNestedStreet() throws Exception {
onFx(() -> { onFx(() -> {
@@ -78,6 +90,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* Toward view label follows the nested street and does not write.
*/
@Test @Test
void towardViewLabelFollowsTheNestedStreetAndDoesNotWrite() throws Exception { void towardViewLabelFollowsTheNestedStreetAndDoesNotWrite() throws Exception {
onFx(() -> { onFx(() -> {
@@ -99,6 +114,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* Both ways binding on a complex path yields the model instance.
*/
@Test @Test
void bothWaysBindingOnAComplexPathYieldsTheModelInstance() throws Exception { void bothWaysBindingOnAComplexPathYieldsTheModelInstance() throws Exception {
onFx(() -> { onFx(() -> {
@@ -117,6 +135,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* Filling a missing address stores the view model and writes the model.
*/
@Test @Test
void fillingAMissingAddressStoresTheViewModelAndWritesTheModel() throws Exception { void fillingAMissingAddressStoresTheViewModelAndWritesTheModel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -137,6 +158,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* Missing address leaves the control and the model unchanged.
*/
@Test @Test
void missingAddressLeavesTheControlAndTheModelUnchanged() throws Exception { void missingAddressLeavesTheControlAndTheModelUnchanged() throws Exception {
onFx(() -> { onFx(() -> {
@@ -151,6 +175,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* Null complex attribute holds null.
*/
@Test @Test
void nullComplexAttributeHoldsNull() throws Exception { void nullComplexAttributeHoldsNull() throws Exception {
onFx(() -> { onFx(() -> {
@@ -163,6 +190,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* Attributes that reach the same address share its view model.
*/
@Test @Test
void attributesThatReachTheSameAddressShareItsViewModel() throws Exception { void attributesThatReachTheSameAddressShareItsViewModel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -183,6 +213,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* Equal addresses keep separate view models.
*/
@Test @Test
void equalAddressesKeepSeparateViewModels() throws Exception { void equalAddressesKeepSeparateViewModels() throws Exception {
onFx(() -> { onFx(() -> {
@@ -206,6 +239,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* A person who is their own friend reuses the same view model.
*/
@Test @Test
void aPersonWhoIsTheirOwnFriendReusesTheSameViewModel() throws Exception { void aPersonWhoIsTheirOwnFriendReusesTheSameViewModel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -219,6 +255,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* Two people who point at each other reuse the view models already built.
*/
@Test @Test
void twoPeopleWhoPointAtEachOtherReuseTheViewModelsAlreadyBuilt() throws Exception { void twoPeopleWhoPointAtEachOtherReuseTheViewModelsAlreadyBuilt() throws Exception {
onFx(() -> { onFx(() -> {
@@ -236,6 +275,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* Enums booleans and date values stay simple values.
*/
@Test @Test
void enumsBooleansAndDateValuesStaySimpleValues() throws Exception { void enumsBooleansAndDateValuesStaySimpleValues() throws Exception {
onFx(() -> { onFx(() -> {
@@ -260,6 +302,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* A collection stays the collection rather than a view model.
*/
@Test @Test
void aCollectionStaysTheCollectionRatherThanAViewModel() throws Exception { void aCollectionStaysTheCollectionRatherThanAViewModel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -281,6 +326,9 @@ class ReachNestedAttributeTest {
}); });
} }
/**
* Two loads of the same person share the view model.
*/
@Test @Test
void twoLoadsOfTheSamePersonShareTheViewModel() throws Exception { void twoLoadsOfTheSamePersonShareTheViewModel() throws Exception {
onFx(() -> { 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 { private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) { if (Platform.isFxApplicationThread()) {
action.run(); action.run();
@@ -330,8 +383,16 @@ class ReachNestedAttributeTest {
} }
} }
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface @FunctionalInterface
private interface FxAction { private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception; 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.assertSame;
import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.fail;
/**
* A repeat places one component in a pane for each model instance.
*/
class RepeatAddressComponentsTest { class RepeatAddressComponentsTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll @BeforeAll
static void startJavaFx() throws InterruptedException { static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1); CountDownLatch started = new CountDownLatch(1);
@@ -43,6 +49,9 @@ class RepeatAddressComponentsTest {
} }
} }
/**
* Addresses are the address instances on the shared list.
*/
@Test @Test
void addressesAreTheAddressInstancesOnTheSharedList() throws Exception { void addressesAreTheAddressInstancesOnTheSharedList() throws Exception {
onFx(() -> { onFx(() -> {
@@ -67,6 +76,9 @@ class RepeatAddressComponentsTest {
}); });
} }
/**
* Each address appears as its own form in list order.
*/
@Test @Test
void eachAddressAppearsAsItsOwnFormInListOrder() throws Exception { void eachAddressAppearsAsItsOwnFormInListOrder() throws Exception {
onFx(() -> { onFx(() -> {
@@ -96,6 +108,9 @@ class RepeatAddressComponentsTest {
}); });
} }
/**
* Adding and removing an address updates the pane and the entity.
*/
@Test @Test
void addingAndRemovingAnAddressUpdatesThePaneAndTheEntity() throws Exception { void addingAndRemovingAnAddressUpdatesThePaneAndTheEntity() throws Exception {
onFx(() -> { onFx(() -> {
@@ -131,6 +146,9 @@ class RepeatAddressComponentsTest {
}); });
} }
/**
* Selection is a separate attribute and the forms do not store it.
*/
@Test @Test
void selectionIsASeparateAttributeAndTheFormsDoNotStoreIt() throws Exception { void selectionIsASeparateAttributeAndTheFormsDoNotStoreIt() throws Exception {
onFx(() -> { onFx(() -> {
@@ -161,6 +179,9 @@ class RepeatAddressComponentsTest {
}); });
} }
/**
* A second address form in the same document is another component.
*/
@Test @Test
void aSecondAddressFormInTheSameDocumentIsAnotherComponent() throws Exception { void aSecondAddressFormInTheSameDocumentIsAnotherComponent() throws Exception {
onFx(() -> { 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) { private static List<String> streets(List<Address> addresses) {
return addresses.stream().map(Address::getStreet).toList(); 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 { private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) { if (Platform.isFxApplicationThread()) {
action.run(); action.run();
@@ -224,8 +256,16 @@ class RepeatAddressComponentsTest {
} }
} }
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface @FunctionalInterface
private interface FxAction { private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception; 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.assertSame;
import static org.junit.jupiter.api.Assertions.fail; 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 { class ShowAListOfSimpleValuesTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll @BeforeAll
static void startJavaFx() throws InterruptedException { static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1); CountDownLatch started = new CountDownLatch(1);
@@ -35,6 +41,9 @@ class ShowAListOfSimpleValuesTest {
} }
} }
/**
* List view and person share the tag list.
*/
@Test @Test
void listViewAndPersonShareTheTagList() throws Exception { void listViewAndPersonShareTheTagList() throws Exception {
onFx(() -> { 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 { private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) { if (Platform.isFxApplicationThread()) {
action.run(); action.run();
@@ -92,8 +106,16 @@ class ShowAListOfSimpleValuesTest {
} }
} }
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface @FunctionalInterface
private interface FxAction { private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception; 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.assertTrue;
import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.fail;
/**
* A direct binding shows one attribute and writes it when the direction says so.
*/
class ShowAndEditOneAttributeTest { class ShowAndEditOneAttributeTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll @BeforeAll
static void startJavaFx() throws InterruptedException { static void startJavaFx() throws InterruptedException {
CountDownLatch started = new CountDownLatch(1); CountDownLatch started = new CountDownLatch(1);
@@ -36,6 +42,9 @@ class ShowAndEditOneAttributeTest {
} }
} }
/**
* Both ways text field shows the name and typing updates the model.
*/
@Test @Test
void bothWaysTextFieldShowsTheNameAndTypingUpdatesTheModel() throws Exception { void bothWaysTextFieldShowsTheNameAndTypingUpdatesTheModel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -51,6 +60,9 @@ class ShowAndEditOneAttributeTest {
}); });
} }
/**
* Toward view label shows the name follows the field and does not write the model.
*/
@Test @Test
void towardViewLabelShowsTheNameFollowsTheFieldAndDoesNotWriteTheModel() throws Exception { void towardViewLabelShowsTheNameFollowsTheFieldAndDoesNotWriteTheModel() throws Exception {
onFx(() -> { onFx(() -> {
@@ -70,6 +82,9 @@ class ShowAndEditOneAttributeTest {
}); });
} }
/**
* Entity setter after load leaves the text control unchanged.
*/
@Test @Test
void entitySetterAfterLoadLeavesTheTextControlUnchanged() throws Exception { void entitySetterAfterLoadLeavesTheTextControlUnchanged() throws Exception {
onFx(() -> { onFx(() -> {
@@ -84,6 +99,9 @@ class ShowAndEditOneAttributeTest {
}); });
} }
/**
* Binding a read only getter fails the load.
*/
@Test @Test
void bindingAReadOnlyGetterFailsTheLoad() throws Exception { void bindingAReadOnlyGetterFailsTheLoad() throws Exception {
onFx(() -> { onFx(() -> {
@@ -97,6 +115,9 @@ class ShowAndEditOneAttributeTest {
}); });
} }
/**
* Binding a method declared on object fails the load.
*/
@Test @Test
void bindingAMethodDeclaredOnObjectFailsTheLoad() throws Exception { void bindingAMethodDeclaredOnObjectFailsTheLoad() throws Exception {
onFx(() -> { onFx(() -> {
@@ -110,6 +131,9 @@ class ShowAndEditOneAttributeTest {
}); });
} }
/**
* Null integer attribute uses an integer property.
*/
@Test @Test
void nullIntegerAttributeUsesAnIntegerProperty() throws Exception { void nullIntegerAttributeUsesAnIntegerProperty() throws Exception {
onFx(() -> { 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 { private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) { if (Platform.isFxApplicationThread()) {
action.run(); action.run();
@@ -153,8 +182,16 @@ class ShowAndEditOneAttributeTest {
} }
} }
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface @FunctionalInterface
private interface FxAction { private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception; 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.assertSame;
import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.fail;
/**
* A message stays on the view model until the developer asks for a dialog.
*/
class ShowMessagesWhenAskedTest { class ShowMessagesWhenAskedTest {
/**
* Starts the JavaFX toolkit once for this class.
*/
@BeforeAll @BeforeAll
static void startJavaFx() throws InterruptedException { static void startJavaFx() throws InterruptedException {
Platform.setImplicitExit(false); Platform.setImplicitExit(false);
@@ -45,6 +51,9 @@ class ShowMessagesWhenAskedTest {
} }
} }
/**
* Adding a message does not open a popup or fill a status bar.
*/
@Test @Test
void addingAMessageDoesNotOpenAPopupOrFillAStatusBar() throws Exception { void addingAMessageDoesNotOpenAPopupOrFillAStatusBar() throws Exception {
onFx(() -> { onFx(() -> {
@@ -64,6 +73,9 @@ class ShowMessagesWhenAskedTest {
}); });
} }
/**
* Direct binding shows the latest message text.
*/
@Test @Test
void directBindingShowsTheLatestMessageText() throws Exception { void directBindingShowsTheLatestMessageText() throws Exception {
onFx(() -> { onFx(() -> {
@@ -82,6 +94,9 @@ class ShowMessagesWhenAskedTest {
}); });
} }
/**
* Opted in helper shows a new message in a popup.
*/
@Test @Test
void optedInHelperShowsANewMessageInAPopup() throws Exception { void optedInHelperShowsANewMessageInAPopup() throws Exception {
onFx(() -> { onFx(() -> {
@@ -104,6 +119,9 @@ class ShowMessagesWhenAskedTest {
}); });
} }
/**
* Updating a binding failure does not open another popup.
*/
@Test @Test
void updatingABindingFailureDoesNotOpenAnotherPopup() throws Exception { void updatingABindingFailureDoesNotOpenAnotherPopup() throws Exception {
onFx(() -> { 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() { private static Set<Window> snapshotWindows() {
return new HashSet<>(Window.getWindows()); 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) { private static Window windowShowing(String text, Set<Window> alreadyOpen) {
for (Window window : Window.getWindows()) { for (Window window : Window.getWindows()) {
if (!alreadyOpen.contains(window) && windowShows(window, text)) { if (!alreadyOpen.contains(window) && windowShows(window, text)) {
@@ -153,6 +183,13 @@ class ShowMessagesWhenAskedTest {
return null; 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) { private static boolean windowShows(Window window, String text) {
if (window.getScene() == null || !(window.getScene().getRoot() instanceof Parent root)) { if (window.getScene() == null || !(window.getScene().getRoot() instanceof Parent root)) {
return false; return false;
@@ -168,10 +205,23 @@ class ShowMessagesWhenAskedTest {
return false; 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) { private static boolean nodeShows(Node node, String text) {
return node instanceof Labeled labeled && text.equals(labeled.getText()); 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) { private static List<Window> windowsOpenedSince(Set<Window> alreadyOpen) {
List<Window> opened = new ArrayList<>(); List<Window> opened = new ArrayList<>();
for (Window window : Window.getWindows()) { for (Window window : Window.getWindows()) {
@@ -182,6 +232,11 @@ class ShowMessagesWhenAskedTest {
return opened; return opened;
} }
/**
* Closes the windows opened during a check.
*
* @param alreadyOpen the windows open before the check
*/
private static void closeWindowsOpenedSince(Set<Window> alreadyOpen) { private static void closeWindowsOpenedSince(Set<Window> alreadyOpen) {
List<Window> open = new ArrayList<>(Window.getWindows()); List<Window> open = new ArrayList<>(Window.getWindows());
for (Window window : open) { 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) { private static void assertMessageIsNotShown(Parent root, String text) {
for (Node node : root.lookupAll("*")) { for (Node node : root.lookupAll("*")) {
if (node instanceof Labeled labeled) { 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 { private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) { if (Platform.isFxApplicationThread()) {
action.run(); action.run();
@@ -233,8 +299,16 @@ class ShowMessagesWhenAskedTest {
} }
} }
/**
* One check that runs on the JavaFX application thread.
*/
@FunctionalInterface @FunctionalInterface
private interface FxAction { private interface FxAction {
/**
* Runs the check.
*
* @throws Exception when the check fails
*/
void run() throws Exception; void run() throws Exception;
} }
} }
@@ -5,11 +5,14 @@ import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty; 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}. * extend {@link de.neitzel.fx.view.ViewModel}.
*/ */
public class BadgeViewModel { public class BadgeViewModel {
/**
* The badge text this view model adds beside the model attributes.
*/
private final StringProperty badge = new SimpleStringProperty(); 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 * @return the badge property
*/ */
@@ -13,10 +13,13 @@ import javafx.scene.layout.VBox;
@ViewModelClass(StreetTitleViewModel.class) @ViewModelClass(StreetTitleViewModel.class)
public class CaptionAddressForm extends VBox { 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"); 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 * @return the model property
*/ */
@@ -9,6 +9,9 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class DeskController { public class DeskController {
/**
* The service the greeting method reads.
*/
private final DeskGreeting greeting; private final DeskGreeting greeting;
/** /**
@@ -3,7 +3,7 @@ package de.neitzel.fx.view.chosen;
import jakarta.inject.Singleton; 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 @Singleton
public class DeskGreeting { public class DeskGreeting {
@@ -10,12 +10,15 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class HandedNameController implements HasViewModel { public class HandedNameController implements HasViewModel {
/**
* The name handed to this controller before the bindings run.
*/
private String handed; private String handed;
/** /**
* Records a label value that is available only from the handed ViewModel. * 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 @Override
public void setViewModel(ViewModel viewModel) { public void setViewModel(ViewModel viewModel) {
@@ -10,6 +10,9 @@ import javafx.beans.property.StringProperty;
*/ */
public class StreetTitleViewModel extends ViewModel { public class StreetTitleViewModel extends ViewModel {
/**
* The caption this view model adds for the street.
*/
private final StringProperty caption = new SimpleStringProperty(); 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 * @return the caption property
*/ */
@@ -4,7 +4,7 @@ import de.neitzel.fx.view.ViewModelClass;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
/** /**
* Controller that names {@link TitledViewModel} for its View. * Controller that names {@link titledviewmodel} for its view.
*/ */
@Singleton @Singleton
@ViewModelClass(TitledViewModel.class) @ViewModelClass(TitledViewModel.class)
@@ -10,6 +10,9 @@ import javafx.beans.property.StringProperty;
*/ */
public class TitledViewModel extends ViewModel { public class TitledViewModel extends ViewModel {
/**
* The title this view model adds beside the model attributes.
*/
private final StringProperty title = new SimpleStringProperty(); 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 * @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. * Component that shows one address.
* <p> * <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 @Singleton
public class AddressForm extends VBox implements HasViewModel { public class AddressForm extends VBox implements HasViewModel {
/**
* The service that supplies the street caption.
*/
private final StreetCaption captions; 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"); private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
/**
* The label filled by the component's own binding.
*/
@FXML @FXML
private Label streetLabel; private Label streetLabel;
/**
* The street field filled by the component's own binding.
*/
@FXML @FXML
private TextField streetField; private TextField streetField;
/**
* The view model handed over before the component's bindings run.
*/
private ViewModel receivedViewModel; private ViewModel receivedViewModel;
/**
* The street text visible when the view model is handed over.
*/
private String streetTextAtHandOff; 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 * @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. * 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 @Override
public void setViewModel(ViewModel viewModel) { public void setViewModel(ViewModel viewModel) {
@@ -7,6 +7,9 @@ import de.neitzel.fx.view.fixture.Address;
*/ */
public class Envelope { public class Envelope {
/**
* The address this model holds.
*/
private Address address; private Address address;
/** /**
@@ -11,10 +11,13 @@ import javafx.scene.layout.VBox;
@Singleton @Singleton
public class EnvelopeForm extends VBox { 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"); 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 * @return the model property
*/ */
@@ -5,6 +5,9 @@ package de.neitzel.fx.view.component;
*/ */
public class Letter { public class Letter {
/**
* The envelope this letter holds.
*/
private Envelope envelope; private Envelope envelope;
/** /**
@@ -5,11 +5,14 @@ import javafx.fxml.FXML;
import javafx.scene.control.TextField; 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 @Singleton
public class WorkingAgeController { public class WorkingAgeController {
/**
* The name field a computed binding reads.
*/
@FXML @FXML
private TextField nameField; private TextField nameField;
@@ -10,6 +10,9 @@ import java.util.Objects;
*/ */
public class Address { public class Address {
/**
* The street.
*/
private String street; private String street;
/** /**
@@ -39,6 +42,12 @@ public class Address {
this.street = street; this.street = street;
} }
/**
* Compares addresses by street.
*
* @param other the other address
* @return {@code true} when the streets are equal
*/
@Override @Override
public boolean equals(Object other) { public boolean equals(Object other) {
if (other instanceof Address address) { if (other instanceof Address address) {
@@ -47,6 +56,11 @@ public class Address {
return false; return false;
} }
/**
* Returns a hash code for the street.
*
* @return the hash code
*/
@Override @Override
public int hashCode() { public int hashCode() {
return Objects.hashCode(street); return Objects.hashCode(street);
@@ -5,6 +5,9 @@ package de.neitzel.fx.view.fixture;
*/ */
public class Household { public class Household {
/**
* The person in this household.
*/
private Person person; private Person person;
/** /**
@@ -9,6 +9,9 @@ import javafx.scene.layout.Pane;
*/ */
public class ObjectHolder extends Pane { public class ObjectHolder extends Pane {
/**
* The value a direct binding writes.
*/
private final ObjectProperty<Object> value = new SimpleObjectProperty<>(this, "value"); private final ObjectProperty<Object> value = new SimpleObjectProperty<>(this, "value");
/** /**
@@ -10,19 +10,61 @@ import java.util.List;
*/ */
public class Person { public class Person {
/**
* The name.
*/
private String name; private String name;
/**
* The age in years, or {@code null} when it has not been set.
*/
private Integer age; private Integer age;
/**
* Whether the person is active.
*/
private boolean active; private boolean active;
/**
* The address this model holds.
*/
private Address address; private Address address;
/**
* The home address.
*/
private Address home; private Address home;
/**
* The work address.
*/
private Address work; private Address work;
/**
* Another person, used to follow a cycle.
*/
private Person friend; private Person friend;
/**
* The person's role.
*/
private Role role; private Role role;
/**
* The birth date.
*/
private LocalDate born; private LocalDate born;
/**
* The last update.
*/
private Date updated; private Date updated;
/**
* The hire date.
*/
private Calendar hired; private Calendar hired;
/**
* The tags. after a view loads, this is the list the control watches.
*/
private List<String> tags; private List<String> tags;
/**
* The addresses. after a view loads, this is the list a repeat watches.
*/
private List<Address> addresses; private List<Address> addresses;
/**
* The selected address, or {@code null} when none is selected.
*/
private Address selectedAddress; 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 * @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 * @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 * @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} * @return the selected address, or {@code null}
*/ */
@@ -10,6 +10,9 @@ import jakarta.inject.Singleton;
@Singleton @Singleton
public class PrefixedAgeConverter implements Converter { public class PrefixedAgeConverter implements Converter {
/**
* The unit written in front of an age.
*/
private final AgeUnit ageUnit; private final AgeUnit ageUnit;
/** /**
@@ -4,6 +4,13 @@ package de.neitzel.fx.view.fixture;
* A simple enum Attribute used by view-loading tests. * A simple enum Attribute used by view-loading tests.
*/ */
public enum Role { public enum Role {
/**
* An ordinary member.
*/
MEMBER, MEMBER,
/**
* An administrator.
*/
ADMIN 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>
@@ -8,6 +8,9 @@ import java.util.HashMap;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Checks that {@link JsonConfiguration#get} reads stored JSON, missing keys, defaults, and objects.
*/
class JsonConfigurationTest { class JsonConfigurationTest {
/** /**
@@ -16,6 +19,9 @@ class JsonConfigurationTest {
* it from JSON representation to the specified class type. * it from JSON representation to the specified class type.
*/ */
/**
* Returns the integer stored for an existing key.
*/
@Test @Test
void testGetExistingKeyWithValidValue() { void testGetExistingKeyWithValidValue() {
// Arrange // Arrange
@@ -34,6 +40,9 @@ class JsonConfigurationTest {
assertEquals(42, value); assertEquals(42, value);
} }
/**
* Returns null when the requested key is absent.
*/
@Test @Test
void testGetNonExistingKey() { void testGetNonExistingKey() {
// Arrange // Arrange
@@ -51,6 +60,9 @@ class JsonConfigurationTest {
assertNull(value); assertNull(value);
} }
/**
* Returns the stored value when the key exists, even if a default was supplied.
*/
@Test @Test
void testGetWithDefaultValueWhenKeyExists() { void testGetWithDefaultValueWhenKeyExists() {
// Arrange // Arrange
@@ -69,6 +81,9 @@ class JsonConfigurationTest {
assertEquals("Hello World", value); assertEquals("Hello World", value);
} }
/**
* Returns the supplied default when the key is absent.
*/
@Test @Test
void testGetWithDefaultValueWhenKeyDoesNotExist() { void testGetWithDefaultValueWhenKeyDoesNotExist() {
// Arrange // Arrange
@@ -86,6 +101,9 @@ class JsonConfigurationTest {
assertEquals("Default Value", value); assertEquals("Default Value", value);
} }
/**
* Reads a JSON object into {@link TestObject}.
*/
@Test @Test
void testGetWithComplexObject() { void testGetWithComplexObject() {
// Arrange // Arrange
@@ -107,20 +125,45 @@ class JsonConfigurationTest {
} }
// Helper class for complex object tests // Helper class for complex object tests
/**
* Value deserialized in the complex-object test.
*/
static class TestObject { static class TestObject {
/**
* The sample name.
*/
private final String name; private final String name;
/**
* The sample age.
*/
private final int age; private final int age;
/**
* Creates a sample with the given name and age.
*
* @param name the sample name
* @param age the sample age
*/
TestObject(String name, int age) { TestObject(String name, int age) {
this.name = name; this.name = name;
this.age = age; this.age = age;
} }
/**
* Returns the sample name.
*
* @return the name
*/
public String name() { public String name() {
return name; return name;
} }
/**
* Returns the sample age.
*
* @return the age
*/
public int age() { public int age() {
return age; return age;
} }
+173
View File
@@ -0,0 +1,173 @@
# Injection
## Introduction
`injection` is a small container. You mark concrete classes, it scans a package, keeps one shared instance of each scanned class, and fills dependencies by type. [`fx`](../fx/README.md) uses it for controllers, components, and converters. You can use it without JavaFX.
The worked example is the person desk in `fx-example`, package `de.neitzel.fx.example`. This guide uses that desk for every feature the desk exercises, then covers the container rules the desk does not hit.
## Quick start
Depend on the module. It brings Reflections and `jakarta.inject-api`. The parent artifact is `de.neitzel.lib:neitzellib:1.0-SNAPSHOT`. Java 21.
```xml
<dependency>
<groupId>de.neitzel.lib</groupId>
<artifactId>injection</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
```
Mark a class with `jakarta.inject.Singleton` or `jakarta.inject.Named`, and take dependencies with `jakarta.inject.Inject`. Open a context on the package that contains those classes.
The desk does it this way. `DeskApp` names the scan root and opens the context. `ViewLoader` then asks that context for the controller, the forms, and the converters. `AgeSuffix` is a `@Named` service. `AgeConverter` takes that service in its constructor.
```java
ApplicationContext context = new ApplicationContext(DeskApp.class);
LoadedView loaded = ViewLoader.load(
DeskController.class.getResource("Desk.fxml"), DeskApp.sample(), context);
```
`DeskApp.start` is that load, then the scene. The class carries `@Config(basePackage = "de.neitzel.fx.example")`.
```java
@Named
public class AgeSuffix {
public String prefix() {
return "years ";
}
}
```
`AgeConverter` in `fx-example` is the full class. It keeps the suffix and implements `Converter`. `getComponent` returns the same instance on every call. `AgeConverter` and `AgeSuffix` are each created once, and every use of the converter shares that one suffix.
## Mark a component
The sections from here on are the container's rules. The desk is the example where a rule shows up on the screen. Rules the desk does not hit are stated on their own.
The scanner looks for `jakarta.inject.Singleton` and `jakarta.inject.Named` on the class. Both annotations mean the same thing here: the class is a component, and it is stored as a singleton. `@Named`'s value is never read. There is no lookup by name.
`AgeSuffix` carries `@Named`. `AgeConverter`, `StreetConverter`, `AddressForm`, `VisitForm`, `NoteForm`, `VisitRow`, `DeskController`, `FormHeading`, `StreetPolicy`, and `VisitedOnConverter` carry `@Singleton`. The age field still shows `years 36`, which is how the desk shows that `@Named` was found.
Put the annotation on each concrete class you want created. The scan uses Reflections' "annotated types" query, which also returns subtypes of an annotated type that sit inside the scan root. An unannotated subclass of a `@Singleton` class can therefore be picked up. Mark the classes you intend to create.
The scanner does not read `@Qualifier`, `Provider`, `Instance`, producer methods, `@PostConstruct`, or `@PreDestroy`. `@Named` on a parameter or a field is not a qualifier. Injection matches the raw class after generics are erased, so a `List<Address>` is looked up as `List`.
There is no `@Component` stereotype. Comments on `Scope` and `@Config` still mention one. Discovery is `@Singleton` and `@Named`.
## Where the container injects
For each component the scanner chooses one constructor, then the `@Inject` fields, then the `@Inject` setters. Members are taken from that class only. Inherited fields and methods are left alone. The chosen constructor and the injected members are made accessible, so they may be non-public.
Constructor choice:
1. More than one constructor annotated with `@Inject` makes the component invalid. It is left out of the context.
2. Exactly one `@Inject` constructor is used, even when other constructors exist.
3. Otherwise the single declared constructor is used. A compiler-generated public no-arg constructor counts. Parameters are allowed, and `@Inject` is not required on that lone constructor.
4. Otherwise a zero-argument constructor is used when one exists.
5. Otherwise the component is invalid.
After `new`, each `@Inject` field is set, then each valid `@Inject` setter is called. A setter is an `@Inject` method that is `void`, has one parameter, and whose name starts with `set`. An `@Inject` method of another shape is skipped and recorded as an error. The component can still be created.
The desk uses all three places:
| Place | Desk class | What is injected |
| --- | --- | --- |
| Constructor | `AgeConverter` | `AgeSuffix` |
| Constructor | `VisitRow` | `DeskController` |
| Field | `StreetConverter` | `StreetPolicy`, on a private field, after the no-arg constructor |
| Setter | `AddressForm.setHeading` | `FormHeading` |
`AddressForm.initialize` runs later, when FXML loads the form, and copies `heading.text()` onto the label. The setter has already run by then. The home form shows the word `Address`.
`VisitRow` is created again for every table row. Each row receives the same `DeskController`, because the controller is a singleton and dependencies are loaded with `getComponent`.
## Ask for an instance
```java
public <T> T getComponent(Class<? extends T> type)
public <T> T createComponent(Class<? extends T> type)
public <T> void addSingleton(Class<? extends T> type, T instance)
```
`getComponent` returns the cached instance. The first call creates it and stores it. Later calls return that object.
`createComponent` always builds a new instance and does not store it. Dependencies of that instance still come from `getComponent`, so they are the shared singletons. `fx` uses this for a second component of the same class in one load, and for every repeated row and table row. The home `AddressForm` is the cached instance. Each `VisitForm` and each `VisitRow` is a new instance, and each of them still sees the one `DeskController` and the one `StreetConverter`.
A type the context does not know produces `IllegalArgumentException` with the message `No component found for type:` and the class name. A failure while calling the constructor or writing a member is wrapped as `RuntimeException` whose message starts with `Fehler beim Erstellen der Instanz für`, with the original failure as the cause.
The context is a plain map. It is not safe to share across threads without external locking. There is no shutdown and no list-all method.
## Choose the scan root
```java
public ApplicationContext(String basePackage)
public ApplicationContext(Class<?> configClass)
```
The string constructor scans that package and its subpackages. The desk uses the class constructor:
```java
ApplicationContext context = new ApplicationContext(DeskApp.class);
```
`@Config` on the class supplies the package when `basePackage` is not empty. `DeskApp` sets it to `de.neitzel.fx.example`, which is also the class's own package. Subpackages are included, so `de.neitzel.fx.example.view` is scanned. Without the annotation, or with `basePackage` left as `""`, the scan root is `configClass.getPackageName()`. The annotation is not inherited, so a superclass `@Config` is ignored.
`@Config` only chooses the scan root. It does not bind properties.
Scan failures are not thrown from either constructor. A package full of broken components still produces a context. Those types are simply absent, and `getComponent` fails later with `No component found for type:`. To see why, scan with `ComponentScanner` and read `getErrors()`.
## Types you can inject
`getComponent` accepts the concrete class. It also accepts a superclass or an interface when exactly one scanned component has that super type. The context stores one `ComponentData` under the concrete class and under each such unique super type, and both keys return the same instance.
A super type implemented by two or more components is ambiguous. It is not a key. `getComponent` on it throws `No component found for type:`. The concrete classes stay available. The desk does not use this: every injection point names a concrete class.
Super types considered for a component are its superclass chain, stopping before `Object`, and the interfaces declared on that class. Interfaces declared only on a superclass are not included. The class itself is not registered twice under this rule.
`ComponentScanner` is the way to see the result before you build a context:
```java
ComponentScanner scanner = new ComponentScanner("de.neitzel.fx.example");
scanner.getInstantiableComponents(); // concrete classes and unique super types
scanner.getNotUniqueTypes(); // super types with two or more components
scanner.getErrors(); // messages, empty when the scan is clean
```
The lists and the map are the scanner's own collections.
A cycle never enters the map. Each side is reported as a missing dependency.
## When creation fails
`new ApplicationContext(...)` stays quiet. Inspect `ComponentScanner.getErrors()` when a type you expected is missing. The messages are:
| Situation | Message |
| --- | --- |
| Two or more `@Inject` constructors | `Multiple @Inject constructors found for ` plus the class name |
| Several constructors, none of them `@Inject`, and none of them zero-argument | `No @Inject constructor and no no-arg constructor found for ` plus the class name |
| `@Inject` method that is not a one-argument `void` method named `set...` | `Invalid @Inject method detected in ` plus the class name, `: `, and the method name. The method is skipped |
| A dependency never becomes a known type | `Component cannot be instantiated: ` plus the class name, then ` \| Missing constructor dependencies: `, ` \| Missing field dependencies: `, or ` \| Missing setter dependencies: `, each followed by the list of classes |
| Every dependency is either known or an ambiguous super type | the same prefix plus ` \| Conflicting super types detected` |
A class with a single `String` constructor and no `@Inject` takes that constructor (rule 3 above). `String` is not a component, so the class is omitted and the error names `class java.lang.String` as the missing constructor dependency.
## Register an instance yourself
`addSingleton` stores an object you already built.
- The type is absent: the instance is stored and `getComponent` returns it. The context does not construct it, and it does not scan again. Components already dropped because a dependency was missing stay dropped.
- The concrete class was found by the scan: `IllegalStateException` with `Type cannot be replaced:` and the class name.
- The key is a unique super type, whose stored component class is the implementation: the super-type entry is replaced. The concrete-class entry is left as it was, so the interface and the class can then return different objects.
`createComponent` on a manually added type has no injection constructor and fails.
The desk does not call `addSingleton`. Its services are the scanned classes.
## Scope
`Scope` has two constants, `SINGLETON` and `PROTOTYPE`. Every class the scanner accepts is stored as `SINGLETON`. Nothing in the scanner assigns `PROTOTYPE`. `getComponent` on a singleton returns the cached instance. `createComponent` is how you get another object of a singleton class, which is what the desk's repeated forms and table rows do.
## What this container does not do
It does not match qualifiers, read `@Named` values, inject by name, bind configuration properties, or call lifecycle methods. It does not create a new instance per injection. `fx` view models are built by `fx`, not by this container.
@@ -19,6 +19,10 @@ import java.util.Objects;
* for resolving and creating components. * for resolving and creating components.
*/ */
public class ApplicationContext { public class ApplicationContext {
/**
* Components this context can create, keyed by the registered type.
* The component scan fills the map, and manual singleton registration updates it.
*/
private final Map<Class<?>, ComponentData> components; private final Map<Class<?>, ComponentData> components;
/** /**
@@ -12,33 +12,54 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertSame;
/**
* Checks that {@link ApplicationContext} injects constructor, field, and setter dependencies.
*/
class ApplicationContextInjectionTest { class ApplicationContextInjectionTest {
/**
* Context scanned from the successful injection fixtures.
*/
private ApplicationContext context; private ApplicationContext context;
/**
* Scans the successful injection fixtures before each test.
*/
@BeforeEach @BeforeEach
void setUp() { void setUp() {
context = new ApplicationContext("de.neitzel.injection.testcomponents.injection.success"); context = new ApplicationContext("de.neitzel.injection.testcomponents.injection.success");
} }
/**
* Resolves a service whose dependency is passed to the constructor.
*/
@Test @Test
void resolvesConstructorInjection() { void resolvesConstructorInjection() {
ConstructorInjectedService service = context.getComponent(ConstructorInjectedService.class); ConstructorInjectedService service = context.getComponent(ConstructorInjectedService.class);
assertNotNull(service.getRootDependency()); assertNotNull(service.getRootDependency());
} }
/**
* Resolves a service whose dependency is injected into a field.
*/
@Test @Test
void resolvesFieldInjection() { void resolvesFieldInjection() {
FieldInjectedService service = context.getComponent(FieldInjectedService.class); FieldInjectedService service = context.getComponent(FieldInjectedService.class);
assertNotNull(service.getRootDependency()); assertNotNull(service.getRootDependency());
} }
/**
* Resolves a service whose dependency is injected through a setter.
*/
@Test @Test
void resolvesSetterInjection() { void resolvesSetterInjection() {
SetterInjectedService service = context.getComponent(SetterInjectedService.class); SetterInjectedService service = context.getComponent(SetterInjectedService.class);
assertNotNull(service.getRootDependency()); assertNotNull(service.getRootDependency());
} }
/**
* Resolves a mixed graph and reuses the same singleton dependency throughout.
*/
@Test @Test
void resolvesMixedInjectionGraphAndSingletonReuse() { void resolvesMixedInjectionGraphAndSingletonReuse() {
MixedInjectionService service = context.getComponent(MixedInjectionService.class); MixedInjectionService service = context.getComponent(MixedInjectionService.class);
@@ -48,6 +69,9 @@ class ApplicationContextInjectionTest {
assertSame(rootDependency, service.getSetterInjectedService().getRootDependency()); assertSame(rootDependency, service.getSetterInjectedService().getRootDependency());
} }
/**
* Creates a new instance while singleton dependencies stay the instances already in the context.
*/
@Test @Test
void createComponentInjectsANewInstanceAndKeepsTheSingleton() { void createComponentInjectsANewInstanceAndKeepsTheSingleton() {
MixedInjectionService singleton = context.getComponent(MixedInjectionService.class); MixedInjectionService singleton = context.getComponent(MixedInjectionService.class);
@@ -63,6 +87,9 @@ class ApplicationContextInjectionTest {
assertSame(singleton.getSetterInjectedService(), created.getSetterInjectedService()); assertSame(singleton.getSetterInjectedService(), created.getSetterInjectedService());
} }
/**
* Keeps a created instance out of the singleton cache.
*/
@Test @Test
void createComponentDoesNotBecomeTheSingleton() { void createComponentDoesNotBecomeTheSingleton() {
MixedInjectionService created = context.createComponent(MixedInjectionService.class); MixedInjectionService created = context.createComponent(MixedInjectionService.class);
@@ -7,8 +7,14 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Checks that the component scanner reports unusable injection plans.
*/
class ComponentScannerInjectionPlanTest { class ComponentScannerInjectionPlanTest {
/**
* Reports an error when a component declares more than one {@code @Inject} constructor.
*/
@Test @Test
void reportsMultipleInjectConstructorsAsError() { void reportsMultipleInjectConstructorsAsError() {
ComponentScanner scanner = new ComponentScanner("de.neitzel.injection.testcomponents.injection.error"); ComponentScanner scanner = new ComponentScanner("de.neitzel.injection.testcomponents.injection.error");
@@ -16,6 +22,9 @@ class ComponentScannerInjectionPlanTest {
assertTrue(scanner.getErrors().stream().anyMatch(error -> error.contains(MultipleInjectConstructorsComponent.class.getName()))); assertTrue(scanner.getErrors().stream().anyMatch(error -> error.contains(MultipleInjectConstructorsComponent.class.getName())));
} }
/**
* Reports an error when every constructor needs arguments and none is marked {@code @Inject}.
*/
@Test @Test
void reportsMissingUsableConstructorAsError() { void reportsMissingUsableConstructorAsError() {
ComponentScanner scanner = new ComponentScanner("de.neitzel.injection.testcomponents.injection.error"); ComponentScanner scanner = new ComponentScanner("de.neitzel.injection.testcomponents.injection.error");
@@ -13,6 +13,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Checks which types the component scanner accepts in the test component packages.
*/
class InjectableComponentScannerTest { class InjectableComponentScannerTest {
/** /**
@@ -3,12 +3,25 @@ package de.neitzel.injection.testcomponents.injection.error;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
/**
* Fixture with two {@code @Inject} constructors, which the scanner must reject.
*/
@Singleton @Singleton
public class MultipleInjectConstructorsComponent { public class MultipleInjectConstructorsComponent {
/**
* Declares an injectable constructor that takes a string.
*
* @param firstDependency unused string dependency
*/
@Inject @Inject
public MultipleInjectConstructorsComponent(String firstDependency) { public MultipleInjectConstructorsComponent(String firstDependency) {
} }
/**
* Declares a second injectable constructor that takes an integer.
*
* @param secondDependency unused integer dependency
*/
@Inject @Inject
public MultipleInjectConstructorsComponent(Integer secondDependency) { public MultipleInjectConstructorsComponent(Integer secondDependency) {
} }
@@ -2,11 +2,24 @@ package de.neitzel.injection.testcomponents.injection.error;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
/**
* Fixture with no default constructor and no {@code @Inject} constructor.
*/
@Singleton @Singleton
public class NoInjectConstructorWithoutDefault { public class NoInjectConstructorWithoutDefault {
/**
* Accepts a string and is not marked for injection.
*
* @param firstDependency unused string dependency
*/
public NoInjectConstructorWithoutDefault(String firstDependency) { public NoInjectConstructorWithoutDefault(String firstDependency) {
} }
/**
* Accepts an integer and is not marked for injection.
*
* @param secondDependency unused integer dependency
*/
public NoInjectConstructorWithoutDefault(Integer secondDependency) { public NoInjectConstructorWithoutDefault(Integer secondDependency) {
} }
} }
@@ -3,15 +3,31 @@ package de.neitzel.injection.testcomponents.injection.success;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
/**
* Fixture whose dependency is passed to the constructor.
*/
@Singleton @Singleton
public class ConstructorInjectedService { public class ConstructorInjectedService {
/**
* Dependency received through the constructor.
*/
private final RootDependency rootDependency; private final RootDependency rootDependency;
/**
* Stores the constructor dependency.
*
* @param rootDependency the dependency
*/
@Inject @Inject
public ConstructorInjectedService(RootDependency rootDependency) { public ConstructorInjectedService(RootDependency rootDependency) {
this.rootDependency = rootDependency; this.rootDependency = rootDependency;
} }
/**
* Returns the constructor dependency.
*
* @return the dependency
*/
public RootDependency getRootDependency() { public RootDependency getRootDependency() {
return rootDependency; return rootDependency;
} }
@@ -3,11 +3,22 @@ package de.neitzel.injection.testcomponents.injection.success;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
/**
* Fixture whose dependency is injected into a field.
*/
@Singleton @Singleton
public class FieldInjectedService { public class FieldInjectedService {
/**
* Dependency injected into the field.
*/
@Inject @Inject
private RootDependency rootDependency; private RootDependency rootDependency;
/**
* Returns the injected dependency.
*
* @return the dependency
*/
public RootDependency getRootDependency() { public RootDependency getRootDependency() {
return rootDependency; return rootDependency;
} }

Some files were not shown because too many files have changed in this diff Show More