Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab158db3e7 | ||
|
|
398883d20a | ||
|
|
eaebe66b0d | ||
|
|
5c7da8673e | ||
|
|
d26f0c3b21 | ||
|
|
0321e0de8d | ||
|
|
a89fe47133 | ||
|
|
17d7550f3a | ||
|
|
01298fc730 | ||
|
|
b1256bf690 | ||
|
|
4bced5f9bb | ||
|
|
f8c65e444e | ||
|
|
a4e5ae865f | ||
|
|
60ba5d5909 | ||
|
|
7984464ab9 | ||
|
|
9474a7acfe | ||
|
|
8f34f51e97 | ||
|
|
6e3036669a | ||
|
|
aab55cbde3 | ||
|
|
99886efa8b | ||
|
|
1b1be747bb | ||
|
|
9835926f4d | ||
|
|
ce95b2626b | ||
|
|
e8ae777939 | ||
|
|
e47e30d602 | ||
|
|
8fbd37c215 |
@@ -43,11 +43,11 @@ Some encryption classes that use format-preserving-encryption.
|
||||
|
||||
### fx
|
||||
|
||||
Some JavaFX additions.
|
||||
JavaFX library that binds an FXML View to an ordinary Java Model. See [fx/README.md](fx/README.md).
|
||||
|
||||
### fx-example
|
||||
|
||||
Some example programs that use the fx component
|
||||
Person desk that shows how to use `fx`. Run `mvn -pl fx-example javafx:run`.
|
||||
|
||||
### gson
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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.
|
||||
|
||||
From the NeitzelLib root, with Java 21:
|
||||
|
||||
```bash
|
||||
mvn -pl fx-example javafx:run
|
||||
```
|
||||
|
||||
The launch class is `de.neitzel.fx.example.Main`. The Model is an ordinary `Person`. The View is `Desk.fxml`.
|
||||
+22
-3
@@ -16,27 +16,46 @@
|
||||
<link.name>${project.artifactId}</link.name>
|
||||
<launcher>${project.artifactId}</launcher>
|
||||
<appName>${project.artifactId}</appName>
|
||||
<main.class>de.neitzel.fx.injectfx.example.Main</main.class>
|
||||
<main.class>de.neitzel.fx.example.Main</main.class>
|
||||
<jar.filename>${project.artifactId}-${project.version}</jar.filename>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- FX dependency -->
|
||||
<dependency>
|
||||
<groupId>de.neitzel.lib</groupId>
|
||||
<artifactId>fx</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>${jar.filename}</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- This module has no Lombok types. Skip the processor so a newer JDK can compile it. -->
|
||||
<proc>none</proc>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-maven-plugin</artifactId>
|
||||
<version>${javafx.maven.plugin}</version>
|
||||
<configuration>
|
||||
<mainClass>${main.class}</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-pmd-plugin</artifactId>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package de.neitzel.fx.component.example;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Simple address model used in the FX example component.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class Address {
|
||||
|
||||
/**
|
||||
* Street name and number.
|
||||
*/
|
||||
private String street;
|
||||
|
||||
/**
|
||||
* City name.
|
||||
*/
|
||||
private String city;
|
||||
|
||||
/**
|
||||
* Default constructor only
|
||||
*/
|
||||
public Address() {
|
||||
// default constructor only
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package de.neitzel.fx.component.example;
|
||||
|
||||
import de.neitzel.fx.component.ComponentLoader;
|
||||
import javafx.application.Application;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
/**
|
||||
* Example JavaFX application demonstrating the {@link de.neitzel.fx.component.ComponentLoader} usage.
|
||||
*/
|
||||
public class ExampleApp extends Application {
|
||||
|
||||
/**
|
||||
* Default constructor only
|
||||
*/
|
||||
public ExampleApp() {
|
||||
// default constructor only
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the JavaFX application.
|
||||
*
|
||||
* @param args command line arguments
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
launch(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes sample model data, loads the FX component and shows the primary stage.
|
||||
*
|
||||
* @param primaryStage the primary stage for this application
|
||||
*/
|
||||
@Override
|
||||
public void start(Stage primaryStage) throws Exception {
|
||||
// Beispielmodel initialisieren
|
||||
Address address = new Address();
|
||||
address.setStreet("Sample Street 1");
|
||||
address.setCity("Sample City");
|
||||
|
||||
Person person = new Person();
|
||||
person.setName("Max Mustermann");
|
||||
person.setAddress(address);
|
||||
|
||||
// ComponentLoader verwenden
|
||||
ComponentLoader loader = new ComponentLoader();
|
||||
Parent root = loader.load(person, "/person.fxml");
|
||||
|
||||
// Scene erstellen und anzeigen
|
||||
Scene scene = new Scene(root);
|
||||
primaryStage.setTitle("ComponentLoader Example");
|
||||
primaryStage.setScene(scene);
|
||||
primaryStage.show();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package de.neitzel.fx.component.example;
|
||||
|
||||
/**
|
||||
* Another Main class as workaround when the JavaFX Application ist started without
|
||||
* taking care os Classloader Requirements of JavaFX. (Important when starting from inside NetBeans!)
|
||||
*/
|
||||
public class Main {
|
||||
|
||||
/**
|
||||
* Default constructor only
|
||||
*/
|
||||
public Main() {
|
||||
// default constructor only
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional main methode to start Application.
|
||||
*
|
||||
* @param args Commandline Arguments.
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
ExampleApp.main(args);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package de.neitzel.fx.component.example;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Simple person model used in the FX example component.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class Person {
|
||||
|
||||
/**
|
||||
* The person's full name.
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* The address of the person.
|
||||
*/
|
||||
private Address address;
|
||||
|
||||
/**
|
||||
* Default constructor only
|
||||
*/
|
||||
public Person() {
|
||||
// default constructor only
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.neitzel.fx.example;
|
||||
|
||||
import de.neitzel.fx.example.model.Address;
|
||||
import de.neitzel.fx.example.model.Person;
|
||||
import de.neitzel.fx.example.view.DeskController;
|
||||
import de.neitzel.fx.view.LoadedView;
|
||||
import de.neitzel.fx.view.MessagePopup;
|
||||
import de.neitzel.fx.view.ViewLoader;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Application;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Opens the person desk for one ordinary {@link Person}.
|
||||
*/
|
||||
public class DeskApp extends Application {
|
||||
|
||||
/**
|
||||
* Creates the JavaFX application.
|
||||
*/
|
||||
public DeskApp() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the person the desk edits.
|
||||
*
|
||||
* @return a person with a home address, one visit, and two tags
|
||||
*/
|
||||
public static Person sample() {
|
||||
Address home = new Address("Sample Street 1", "Sample City");
|
||||
Address visit = new Address("Work Road 2", "Office City");
|
||||
Person person = new Person();
|
||||
person.setName("Ada Lovelace");
|
||||
person.setAge(36);
|
||||
person.setHome(home);
|
||||
person.setVisits(new ArrayList<>(List.of(visit)));
|
||||
person.setSelectedVisit(visit);
|
||||
person.setTags(new ArrayList<>(List.of("dns", "math")));
|
||||
return person;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the desk.
|
||||
*
|
||||
* @param args unused
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
launch(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the desk for {@link #sample()} and shows it.
|
||||
*
|
||||
* @param stage the primary stage
|
||||
*/
|
||||
@Override
|
||||
public void start(Stage stage) {
|
||||
ApplicationContext context = new ApplicationContext(DeskApp.class);
|
||||
Person person = sample();
|
||||
LoadedView loaded = ViewLoader.load(DeskController.class.getResource("Desk.fxml"), person, context);
|
||||
MessagePopup.showNewMessages(loaded.getViewModel());
|
||||
stage.setTitle("fx example");
|
||||
stage.setScene(new Scene(loaded.getRoot()));
|
||||
stage.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.neitzel.fx.example;
|
||||
|
||||
import javafx.application.Application;
|
||||
|
||||
/**
|
||||
* Launches the example without extending {@link Application}.
|
||||
* <p>
|
||||
* JavaFX on the classpath is started from a class that is not itself an {@link Application}.
|
||||
*/
|
||||
public final class Main {
|
||||
|
||||
private Main() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the person desk.
|
||||
*
|
||||
* @param args unused
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
Application.launch(DeskApp.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.neitzel.fx.example.model;
|
||||
|
||||
/**
|
||||
* An ordinary address the desk edits.
|
||||
*/
|
||||
public class Address {
|
||||
|
||||
private String street = "";
|
||||
private String city = "";
|
||||
|
||||
/**
|
||||
* Creates an empty address.
|
||||
*/
|
||||
public Address() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an address with the given street and city.
|
||||
*
|
||||
* @param street the street
|
||||
* @param city the city
|
||||
*/
|
||||
public Address(String street, String city) {
|
||||
this.street = street;
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the street.
|
||||
*
|
||||
* @return the street
|
||||
*/
|
||||
public String getStreet() {
|
||||
return street;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the street.
|
||||
*
|
||||
* @param street the street
|
||||
*/
|
||||
public void setStreet(String street) {
|
||||
this.street = street;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the city.
|
||||
*
|
||||
* @return the city
|
||||
*/
|
||||
public String getCity() {
|
||||
return city;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the city.
|
||||
*
|
||||
* @param city the city
|
||||
*/
|
||||
public void setCity(String city) {
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the street and city, for a combo box row.
|
||||
*
|
||||
* @return the street and city
|
||||
*/
|
||||
@Override
|
||||
public String toString() {
|
||||
return street + ", " + city;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package de.neitzel.fx.example.model;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* An ordinary person the desk edits.
|
||||
* <p>
|
||||
* Read-write attributes are what the View binds to. The class carries no JavaFX types.
|
||||
*/
|
||||
public class Person {
|
||||
|
||||
private String name = "";
|
||||
private Integer age;
|
||||
private Address home;
|
||||
private List<Address> visits = new ArrayList<>();
|
||||
private Address selectedVisit;
|
||||
private List<String> tags = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Creates an empty person.
|
||||
*/
|
||||
public Person() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name.
|
||||
*
|
||||
* @return the name
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the name.
|
||||
*
|
||||
* @param name the name
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the age in years.
|
||||
*
|
||||
* @return the age, or {@code null} when none is set
|
||||
*/
|
||||
public Integer getAge() {
|
||||
return age;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the age in years.
|
||||
*
|
||||
* @param age the age
|
||||
*/
|
||||
public void setAge(Integer age) {
|
||||
this.age = age;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the home address.
|
||||
*
|
||||
* @return the home address, or {@code null} when none is set
|
||||
*/
|
||||
public Address getHome() {
|
||||
return home;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the home address.
|
||||
*
|
||||
* @param home the home address
|
||||
*/
|
||||
public void setHome(Address home) {
|
||||
this.home = home;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the visits.
|
||||
* <p>
|
||||
* After the View has loaded, this is the list the View is watching.
|
||||
*
|
||||
* @return the visits
|
||||
*/
|
||||
public List<Address> getVisits() {
|
||||
return visits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the visits.
|
||||
*
|
||||
* @param visits the visits
|
||||
*/
|
||||
public void setVisits(List<Address> visits) {
|
||||
this.visits = visits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the visit chosen in the combo box.
|
||||
*
|
||||
* @return the selected visit, or {@code null} when none is selected
|
||||
*/
|
||||
public Address getSelectedVisit() {
|
||||
return selectedVisit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the visit chosen in the combo box.
|
||||
*
|
||||
* @param selectedVisit the selected visit
|
||||
*/
|
||||
public void setSelectedVisit(Address selectedVisit) {
|
||||
this.selectedVisit = selectedVisit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tags.
|
||||
* <p>
|
||||
* After the View has loaded, this is the list the View is watching.
|
||||
*
|
||||
* @return the tags
|
||||
*/
|
||||
public List<String> getTags() {
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the tags.
|
||||
*
|
||||
* @param tags the tags
|
||||
*/
|
||||
public void setTags(List<String> tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 address.
|
||||
* <p>
|
||||
* The class is the root and the Controller of {@code AddressForm.fxml}. A parent View sets
|
||||
* {@link #modelProperty()} from a Direct binding. A repeated row gets its own instance.
|
||||
*/
|
||||
@Singleton
|
||||
public class AddressForm extends VBox {
|
||||
|
||||
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
|
||||
|
||||
/**
|
||||
* Creates an empty form. The container calls this constructor.
|
||||
*/
|
||||
public AddressForm() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model property a parent View sets.
|
||||
*
|
||||
* @return the model property
|
||||
*/
|
||||
public ObjectProperty<Object> modelProperty() {
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the address this form edits.
|
||||
*
|
||||
* @return the address, or {@code null} when none is set
|
||||
*/
|
||||
public Object getModel() {
|
||||
return model.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the address this form edits.
|
||||
*
|
||||
* @param model the address
|
||||
*/
|
||||
public void setModel(Object model) {
|
||||
this.model.set(model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.neitzel.fx.example.view;
|
||||
|
||||
import de.neitzel.fx.view.Converter;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* Translates an age between the Model and the text field.
|
||||
*/
|
||||
@Singleton
|
||||
public class AgeConverter implements Converter {
|
||||
|
||||
private final AgeSuffix suffix;
|
||||
|
||||
/**
|
||||
* Creates a converter that reads its suffix from the injected service.
|
||||
*
|
||||
* @param suffix the suffix written in front of the age
|
||||
*/
|
||||
@Inject
|
||||
public AgeConverter(AgeSuffix suffix) {
|
||||
this.suffix = suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the suffix in front of the age.
|
||||
*
|
||||
* @param attributeValue the age
|
||||
* @return the text shown in the field
|
||||
*/
|
||||
@Override
|
||||
public Object toControlValue(Object attributeValue) {
|
||||
if (attributeValue == null) {
|
||||
return "";
|
||||
}
|
||||
return suffix.prefix() + attributeValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an age that starts with the suffix.
|
||||
*
|
||||
* @param controlValue the field text
|
||||
* @return the age
|
||||
*/
|
||||
@Override
|
||||
public Object toAttributeValue(Object controlValue) {
|
||||
String text = controlValue == null ? "" : controlValue.toString();
|
||||
String prefix = suffix.prefix();
|
||||
if (!text.startsWith(prefix)) {
|
||||
throw new IllegalArgumentException("Age must start with \"" + prefix.trim() + "\": " + text);
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(text.substring(prefix.length()).trim());
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException("Age must be a whole number: " + text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.neitzel.fx.example.view;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* The suffix an {@link AgeConverter} writes in front of an age.
|
||||
*/
|
||||
@Singleton
|
||||
public class AgeSuffix {
|
||||
|
||||
/**
|
||||
* Creates the suffix used by the desk.
|
||||
*/
|
||||
public AgeSuffix() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text written in front of the age.
|
||||
*
|
||||
* @return the suffix, including its trailing space
|
||||
*/
|
||||
public String prefix() {
|
||||
return "years ";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package de.neitzel.fx.example.view;
|
||||
|
||||
import de.neitzel.fx.example.model.Address;
|
||||
import de.neitzel.fx.view.HasViewModel;
|
||||
import de.neitzel.fx.view.Message;
|
||||
import de.neitzel.fx.view.ViewModel;
|
||||
import jakarta.inject.Singleton;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.TextField;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Controller for the person desk.
|
||||
* <p>
|
||||
* The container creates this class and injects nothing else. {@link #setViewModel} receives the
|
||||
* ViewModel before the binding elements run, so {@link #greeting()} can read the name field on
|
||||
* its first call.
|
||||
*/
|
||||
@Singleton
|
||||
public class DeskController implements HasViewModel {
|
||||
|
||||
@FXML
|
||||
private TextField nameField;
|
||||
|
||||
@FXML
|
||||
private TextField tagField;
|
||||
|
||||
private ViewModel viewModel;
|
||||
|
||||
/**
|
||||
* Creates the controller. The container calls this constructor.
|
||||
*/
|
||||
public DeskController() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives the ViewModel for the person on the desk.
|
||||
*
|
||||
* @param viewModel the ViewModel
|
||||
*/
|
||||
@Override
|
||||
public void setViewModel(ViewModel viewModel) {
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the greeting written to the label.
|
||||
* <p>
|
||||
* The method reads the name field. It does not write the Model.
|
||||
*
|
||||
* @return the greeting
|
||||
*/
|
||||
public String greeting() {
|
||||
String name = nameField.getText();
|
||||
if (name == null || name.isBlank()) {
|
||||
return "Hello";
|
||||
}
|
||||
return "Hello, " + name.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a visit and leaves the previous selection in place.
|
||||
*/
|
||||
@FXML
|
||||
public void addVisit() {
|
||||
visits().add(new Address("New street", "New city"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the tag field's text to the tag list and clears the field.
|
||||
*/
|
||||
@FXML
|
||||
public void addTag() {
|
||||
String tag = tagField.getText();
|
||||
if (tag == null || tag.isBlank()) {
|
||||
return;
|
||||
}
|
||||
tags().add(tag.trim());
|
||||
tagField.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an info Message that quotes the name already stored on the Model.
|
||||
*/
|
||||
@FXML
|
||||
public void leaveNote() {
|
||||
Object name = viewModel.property("name").getValue();
|
||||
viewModel.addMessage(new Message(
|
||||
Message.Severity.INFO,
|
||||
"The model already has the name " + name + ".",
|
||||
"desk"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Address> visits() {
|
||||
return (List<Address>) viewModel.property("visits").getValue();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<String> tags() {
|
||||
return (List<String>) viewModel.property("tags").getValue();
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package de.neitzel.fx.injectfx.example;
|
||||
|
||||
import javafx.application.Application;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Minimal JavaFX application launcher used by the examples.
|
||||
*
|
||||
* <p>This class initializes the JavaFX runtime, loads {@code MainWindow.fxml} and shows
|
||||
* the primary stage. It is intended as a small bootstrap used by IDEs or test runs
|
||||
* to start the JavaFX UI for demonstration purposes.
|
||||
*/
|
||||
public class JavaFXApp extends Application {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(JavaFXApp.class.getName());
|
||||
|
||||
/**
|
||||
* Default constructor only.
|
||||
*
|
||||
* <p>The constructor does not perform UI initialization. UI setup happens in {@link #start(Stage)}
|
||||
* after JavaFX has set up the application thread.
|
||||
*/
|
||||
public JavaFXApp() {
|
||||
// default constructor only
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
launch(args);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(Stage primaryStage) {
|
||||
try {
|
||||
primaryStage.setTitle("Hello World!");
|
||||
FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("MainWindow.fxml"));
|
||||
Parent root = fxmlLoader.load();
|
||||
primaryStage.setScene(new Scene(root));
|
||||
primaryStage.show();
|
||||
} catch (Exception ex) {
|
||||
LOGGER.log(Level.SEVERE, "Exception while starting JavaFXApp", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package de.neitzel.fx.injectfx.example;
|
||||
|
||||
/**
|
||||
* Another Main class as workaround when the JavaFX Application ist started without
|
||||
* taking care os Classloader Requirements of JavaFX. (Important when starting from inside NetBeans!)
|
||||
*/
|
||||
public class Main {
|
||||
|
||||
/**
|
||||
* Creates a new helper instance of {@code Main}.
|
||||
*
|
||||
* <p>This constructor is intentionally empty. The {@code Main} class serves as an alternative
|
||||
* entry point to launch the JavaFX application via {@link JavaFXApp#main(String[])} in
|
||||
* environments (IDEs, build tools) where directly starting a class that extends
|
||||
* {@code javafx.application.Application} may fail due to classloader or JVM startup constraints.
|
||||
*
|
||||
* <p>Do not perform application initialization here; use the static {@code main} method to
|
||||
* start the JavaFX runtime. This constructor exists to allow tooling and frameworks to
|
||||
* instantiate the class reflectively if required.
|
||||
*/
|
||||
public Main() {
|
||||
// default constructor only
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional main methode to start Application.
|
||||
*
|
||||
* @param args Commandline Arguments.
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
JavaFXApp.main(args);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package de.neitzel.fx.injectfx.example;
|
||||
|
||||
import javafx.event.ActionEvent;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.fxml.Initializable;
|
||||
import javafx.scene.control.TextField;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.ResourceBundle;
|
||||
|
||||
/**
|
||||
* Controller for the MainWindow FXML view.
|
||||
*
|
||||
* <p>This class acts as the backing controller for the {@code MainWindow.fxml} UI. It demonstrates a
|
||||
* minimal example of a JavaFX controller that:
|
||||
* <ul>
|
||||
* <li>receives UI element injection via {@link FXML} (the {@link #textField} field),</li>
|
||||
* <li>initializes the view in {@link #initialize(URL, ResourceBundle)}, and</li>
|
||||
* <li>handles user actions via {@link #onButtonClick(ActionEvent)}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Usage notes:
|
||||
* <ul>
|
||||
* <li>Fields annotated with {@link FXML} are injected by the {@code FXMLLoader} before
|
||||
* {@link #initialize(URL, ResourceBundle)} is called — do not expect them to be non-null
|
||||
* in the constructor.</li>
|
||||
* <li>All UI updates must run on the JavaFX Application Thread. Methods called by FXML
|
||||
* (such as {@link #onButtonClick(ActionEvent)}) are executed on that thread by the
|
||||
* JavaFX runtime.</li>
|
||||
* <li>The controller keeps a simple click counter and updates {@link #textField} to reflect
|
||||
* the current count using {@link #displayCounter()}.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class MainWindow implements Initializable {
|
||||
|
||||
/**
|
||||
* Counter for button clicks shown in the UI.
|
||||
*/
|
||||
private int counter = 0;
|
||||
|
||||
/**
|
||||
* Text field UI control showing the click counter.
|
||||
*/
|
||||
@FXML
|
||||
private TextField textField;
|
||||
|
||||
/**
|
||||
* Default constructor only
|
||||
*/
|
||||
public MainWindow() {
|
||||
// default constructor only
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
displayCounter();
|
||||
}
|
||||
|
||||
private void displayCounter() {
|
||||
textField.setText("Click counter: " + counter);
|
||||
}
|
||||
|
||||
@FXML
|
||||
private void onButtonClick(ActionEvent actionEvent) {
|
||||
counter++;
|
||||
displayCounter();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.layout.AnchorPane?>
|
||||
<AnchorPane xmlns:fx="http://javafx.com/fxml"
|
||||
xmlns:nfx="http://example.com/nfx"
|
||||
xmlns="http://javafx.com/javafx"
|
||||
fx:controller="de.neitzel.fx.component.ComponentController"
|
||||
prefWidth="300" prefHeight="100">
|
||||
<children>
|
||||
<TextField layoutX="10" layoutY="10" prefWidth="280"
|
||||
nfx:target="street" nfx:direction="bidirectional"/>
|
||||
<TextField layoutX="10" layoutY="50" prefWidth="280"
|
||||
nfx:target="city" nfx:direction="bidirectional"/>
|
||||
</children>
|
||||
</AnchorPane>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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"/>
|
||||
<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,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.geometry.Insets?>
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.ComboBox?>
|
||||
<?import javafx.scene.control.Label?>
|
||||
<?import javafx.scene.control.ListView?>
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.layout.ColumnConstraints?>
|
||||
<?import javafx.scene.layout.GridPane?>
|
||||
<?import javafx.scene.layout.HBox?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import de.neitzel.fx.example.view.AddressForm?>
|
||||
<?import de.neitzel.fx.view.BindingList?>
|
||||
<?import de.neitzel.fx.view.ComputedBinding?>
|
||||
<?import de.neitzel.fx.view.DirectBinding?>
|
||||
<?import de.neitzel.fx.view.Repeat?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
|
||||
fx:controller="de.neitzel.fx.example.view.DeskController"
|
||||
spacing="8" prefWidth="520">
|
||||
<padding>
|
||||
<Insets top="12" right="12" bottom="12" left="12"/>
|
||||
</padding>
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="nameField" property="text" path="name" direction="both"/>
|
||||
<DirectBinding control="ageField" property="text" path="age" direction="both"
|
||||
converter="de.neitzel.fx.example.view.AgeConverter"/>
|
||||
<ComputedBinding control="greetingLabel" property="text" method="#greeting"/>
|
||||
<DirectBinding control="homeForm" property="model" path="home" direction="both"/>
|
||||
<DirectBinding control="homeStreetLabel" property="text" path="home.street" direction="view"/>
|
||||
<Repeat pane="visitPane" component="de.neitzel.fx.example.view.AddressForm" collection="visits"/>
|
||||
<DirectBinding control="visitChoice" property="items" path="visits" direction="view"/>
|
||||
<DirectBinding control="visitChoice" property="value" path="selectedVisit" direction="both"/>
|
||||
<DirectBinding control="tagList" property="items" path="tags" direction="view"/>
|
||||
<DirectBinding control="noticeLabel" property="text" path="latestMessage" direction="view"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
|
||||
<Label text="Person" style="-fx-font-size: 16px;"/>
|
||||
<GridPane hgap="8" vgap="6">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints minWidth="110"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" minWidth="280"/>
|
||||
</columnConstraints>
|
||||
<Label text="Name" GridPane.rowIndex="0" GridPane.columnIndex="0"/>
|
||||
<TextField fx:id="nameField" id="nameField" GridPane.rowIndex="0" GridPane.columnIndex="1"/>
|
||||
<Label text="Age" GridPane.rowIndex="1" GridPane.columnIndex="0"/>
|
||||
<TextField fx:id="ageField" id="ageField" promptText="years 36"
|
||||
GridPane.rowIndex="1" GridPane.columnIndex="1"/>
|
||||
<Label text="Greeting" GridPane.rowIndex="2" GridPane.columnIndex="0"/>
|
||||
<Label fx:id="greetingLabel" id="greetingLabel" GridPane.rowIndex="2" GridPane.columnIndex="1"/>
|
||||
<Label text="Home street" GridPane.rowIndex="3" GridPane.columnIndex="0"/>
|
||||
<Label fx:id="homeStreetLabel" id="homeStreetLabel" GridPane.rowIndex="3" GridPane.columnIndex="1"/>
|
||||
</GridPane>
|
||||
|
||||
<Label text="Home"/>
|
||||
<AddressForm fx:id="homeForm"/>
|
||||
|
||||
<Label text="Visits"/>
|
||||
<HBox spacing="8">
|
||||
<ComboBox fx:id="visitChoice" id="visitChoice" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
|
||||
<Button text="Add visit" onAction="#addVisit"/>
|
||||
</HBox>
|
||||
<VBox fx:id="visitPane" id="visitPane" spacing="4"/>
|
||||
|
||||
<Label text="Tags"/>
|
||||
<ListView fx:id="tagList" id="tagList" prefHeight="80"/>
|
||||
<HBox spacing="8">
|
||||
<TextField fx:id="tagField" id="tagField" promptText="Tag" HBox.hgrow="ALWAYS"/>
|
||||
<Button text="Add tag" onAction="#addTag"/>
|
||||
</HBox>
|
||||
|
||||
<Label text="Notice"/>
|
||||
<Label fx:id="noticeLabel" id="noticeLabel" wrapText="true"/>
|
||||
<Button text="Leave a note" onAction="#leaveNote"/>
|
||||
</VBox>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.Button?>
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.layout.AnchorPane?>
|
||||
<AnchorPane xmlns:fx="http://javafx.com/fxml/1" prefHeight="127.0" prefWidth="209.0"
|
||||
xmlns="http://javafx.com/javafx/17.0.2-ea" fx:controller="de.neitzel.fx.injectfx.example.MainWindow">
|
||||
<children>
|
||||
<Button fx:id="button" layoutX="44.0" layoutY="70.0" mnemonicParsing="false" onAction="#onButtonClick"
|
||||
text="Click Me"/>
|
||||
<TextField fx:id="textField" layoutX="14.0" layoutY="24.0"/>
|
||||
</children>
|
||||
</AnchorPane>
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.layout.AnchorPane?>
|
||||
<?import javafx.scene.layout.Pane?>
|
||||
<AnchorPane xmlns:fx="http://javafx.com/fxml"
|
||||
xmlns:nfx="http://example.com/nfx"
|
||||
xmlns="http://javafx.com/javafx"
|
||||
fx:controller="de.neitzel.fx.component.ComponentController"
|
||||
prefWidth="300" prefHeight="180">
|
||||
<children>
|
||||
<TextField layoutX="10" layoutY="10" prefWidth="280"
|
||||
nfx:target="name" nfx:direction="bidirectional"/>
|
||||
<Pane layoutX="10" layoutY="50" prefWidth="280" prefHeight="110"
|
||||
nfx:target="address" nfx:direction="bidirectional"
|
||||
nfx:source="/address.fxml"/>
|
||||
</children>
|
||||
</AnchorPane>
|
||||
@@ -0,0 +1,118 @@
|
||||
package de.neitzel.fx.example;
|
||||
|
||||
import de.neitzel.fx.example.model.Person;
|
||||
import de.neitzel.fx.example.view.AddressForm;
|
||||
import de.neitzel.fx.example.view.DeskController;
|
||||
import de.neitzel.fx.view.LoadedView;
|
||||
import de.neitzel.fx.view.ViewLoader;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
/**
|
||||
* Loads the person desk and checks the bindings an application developer can see.
|
||||
*/
|
||||
class DeskLoadsTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deskEditsThePersonAndKeepsTheGreetingCurrent() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = DeskApp.sample();
|
||||
ApplicationContext context = new ApplicationContext(DeskApp.class);
|
||||
LoadedView loaded = ViewLoader.load(DeskController.class.getResource("Desk.fxml"), person, context);
|
||||
Parent root = loaded.getRoot();
|
||||
|
||||
TextField nameField = (TextField) root.lookup("#nameField");
|
||||
TextField ageField = (TextField) root.lookup("#ageField");
|
||||
Label greeting = (Label) root.lookup("#greetingLabel");
|
||||
Label homeStreet = (Label) root.lookup("#homeStreetLabel");
|
||||
TextField street = (TextField) homeForm(root).lookup("#streetField");
|
||||
VBox visits = (VBox) root.lookup("#visitPane");
|
||||
ListView<?> tags = (ListView<?>) root.lookup("#tagList");
|
||||
|
||||
assertEquals("Ada Lovelace", nameField.getText());
|
||||
assertEquals("Hello, Ada Lovelace", greeting.getText());
|
||||
assertEquals("years 36", ageField.getText());
|
||||
assertEquals("Sample Street 1", street.getText());
|
||||
assertEquals("Sample Street 1", homeStreet.getText());
|
||||
assertEquals(1, visits.getChildren().size());
|
||||
assertEquals(2, tags.getItems().size());
|
||||
|
||||
nameField.setText("Grace");
|
||||
assertEquals("Grace", person.getName());
|
||||
assertEquals("Hello, Grace", greeting.getText());
|
||||
|
||||
ageField.setText("years 41");
|
||||
assertEquals(Integer.valueOf(41), person.getAge());
|
||||
ageField.setText("nope");
|
||||
assertEquals(Integer.valueOf(41), person.getAge());
|
||||
assertNotNull(loaded.getViewModel().getLatestMessage());
|
||||
assertEquals("age", loaded.getViewModel().getLatestMessage().getSource());
|
||||
|
||||
street.setText("Other Street");
|
||||
assertEquals("Other Street", person.getHome().getStreet());
|
||||
assertEquals("Other Street", homeStreet.getText());
|
||||
|
||||
person.getTags().add("fx");
|
||||
assertEquals(3, tags.getItems().size());
|
||||
});
|
||||
}
|
||||
|
||||
private static AddressForm homeForm(Parent root) {
|
||||
for (Node child : root.getChildrenUnmodifiable()) {
|
||||
if (child instanceof AddressForm form) {
|
||||
return form;
|
||||
}
|
||||
}
|
||||
fail("home form was not loaded");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void onFx(Runnable check) throws InterruptedException {
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
check.run();
|
||||
} catch (Throwable thrown) {
|
||||
failure.set(thrown);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX check did not finish");
|
||||
}
|
||||
if (failure.get() != null) {
|
||||
fail("desk check failed", failure.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
# 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.
|
||||
|
||||
The worked example is the `fx-example` module, package `de.neitzel.fx.example`. From the NeitzelLib root:
|
||||
|
||||
```bash
|
||||
mvn -pl fx-example javafx:run
|
||||
```
|
||||
|
||||
The desk edits one person: name, age, greeting, home address, visits, and tags.
|
||||
|
||||
## What you write
|
||||
|
||||
| Piece | Role |
|
||||
| --- | --- |
|
||||
| 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.
|
||||
|
||||
## 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
|
||||
ApplicationContext context = new ApplicationContext(DeskApp.class);
|
||||
Person person = DeskApp.sample();
|
||||
LoadedView loaded = ViewLoader.load(
|
||||
DeskController.class.getResource("Desk.fxml"), person, context);
|
||||
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.
|
||||
|
||||
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
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="nameField" property="text" path="name" direction="both"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<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.
|
||||
|
||||
| Direction | What it does |
|
||||
| --- | --- |
|
||||
| `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. |
|
||||
| `model` | Shows the current value, writes the attribute when the control changes, and does not push later property changes back to the control. |
|
||||
|
||||
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.
|
||||
|
||||
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 list is not indexed in a Path. Repeat a Component, or hand the list to a control.
|
||||
|
||||
## 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.
|
||||
|
||||
```xml
|
||||
<DirectBinding control="ageField" property="text" path="age" direction="both"
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
```xml
|
||||
<ComputedBinding control="greetingLabel" property="text" method="#greeting"/>
|
||||
```
|
||||
|
||||
```java
|
||||
public String greeting() {
|
||||
String name = nameField.getText();
|
||||
if (name == null || name.isBlank()) {
|
||||
return "Hello";
|
||||
}
|
||||
return "Hello, " + name.trim();
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
```java
|
||||
@Singleton
|
||||
public class DeskController implements HasViewModel {
|
||||
@Override
|
||||
public void setViewModel(ViewModel viewModel) {
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`@FXML` fields are filled during load. A method used as `onAction` is ordinary FXML and is separate from a Computed binding.
|
||||
|
||||
## 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`.
|
||||
|
||||
```java
|
||||
@Singleton
|
||||
public class AddressForm extends VBox {
|
||||
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
|
||||
|
||||
public ObjectProperty<Object> modelProperty() {
|
||||
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
|
||||
<DirectBinding control="homeForm" property="model" path="home" direction="both"/>
|
||||
<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.
|
||||
|
||||
A nested Component is the same mechanism: the child document names the next class as an element.
|
||||
|
||||
## 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.
|
||||
|
||||
```xml
|
||||
<Repeat pane="visitPane" component="de.neitzel.fx.example.view.AddressForm" collection="visits"/>
|
||||
<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.
|
||||
|
||||
```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
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Address> visits() {
|
||||
return (List<Address>) viewModel.property("visits").getValue();
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
```xml
|
||||
<DirectBinding control="tagList" property="items" path="tags" direction="view"/>
|
||||
<ListView fx:id="tagList"/>
|
||||
```
|
||||
|
||||
The desk's "Add tag" button appends to `tags` the same way "Add visit" appends to `visits`.
|
||||
|
||||
## 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:
|
||||
|
||||
```java
|
||||
viewModel.addMessage(new Message(
|
||||
Message.Severity.INFO,
|
||||
"The model already has the name " + name + ".",
|
||||
"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.
|
||||
|
||||
The library does not open a popup or fill a status bar. Bind `latestMessage` when you want the text on a control:
|
||||
|
||||
```xml
|
||||
<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.
|
||||
|
||||
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.
|
||||
|
||||
`LoadedView.property(String)` returns the JavaFX property for an attribute name, for `latestMessage`, or for a JavaFX property declared on a ViewModel subclass.
|
||||
|
||||
## Choosing the ViewModel class
|
||||
|
||||
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.
|
||||
|
||||
```java
|
||||
@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.
|
||||
|
||||
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`.
|
||||
|
||||
## Packages
|
||||
|
||||
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.
|
||||
@@ -1,226 +0,0 @@
|
||||
package de.neitzel.fx.component;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.property.SimpleFloatProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.beans.property.SimpleLongProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AutoViewModel automatically exposes JavaFX properties for all readable/writable fields
|
||||
* of a given POJO model. It creates appropriate Property instances and keeps them in a map
|
||||
* for lookup by field name.
|
||||
*
|
||||
* @param <T> the type of the underlying model
|
||||
*/
|
||||
@Slf4j
|
||||
public class AutoViewModel<T> {
|
||||
|
||||
/**
|
||||
* The wrapped model instance.
|
||||
* -- GETTER --
|
||||
* Retrieves the model associated with this AutoViewModel.
|
||||
*/
|
||||
@Getter
|
||||
private final T model;
|
||||
|
||||
/**
|
||||
* Map of field name to JavaFX Property instance exposing that field's value.
|
||||
*/
|
||||
private final Map<String, Property<?>> properties = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Constructs an AutoViewModel for the provided model and initializes properties via reflection.
|
||||
*
|
||||
* @param model the POJO model whose getters/setters are used to create JavaFX properties
|
||||
*/
|
||||
public AutoViewModel(T model) {
|
||||
this.model = model;
|
||||
initProperties();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a mapping of field names to JavaFX properties for the associated model object.
|
||||
* <p>
|
||||
* This method utilizes reflection to iterate through the public no-argument getters
|
||||
* of the model's class to create corresponding JavaFX properties. It performs the following:
|
||||
* <p>
|
||||
* 1. Identifies methods that adhere to the getter naming conventions (e.g., `getFieldName` or `isFieldName`).
|
||||
* 2. Maps the field name derived from the getter method to a JavaFX property, creating an appropriate
|
||||
* property type based on the getter's return type.
|
||||
* 3. Adds a listener to each property to bind updates from the property back to the model object
|
||||
* using the corresponding setter method, if available. This ensures bi-directional synchronization
|
||||
* between the ViewModel and the underlying model.
|
||||
* <p>
|
||||
* Exceptions that may occur during reflective operations (e.g., invoking methods) are caught
|
||||
* and logged to avoid runtime failures.
|
||||
*/
|
||||
private void initProperties() {
|
||||
for (Method getter : model.getClass().getMethods()) {
|
||||
if (isGetter(getter)) {
|
||||
String fieldName = getFieldName(getter);
|
||||
Object value = invokeGetter(getter);
|
||||
|
||||
Property<?> prop = toProperty(value);
|
||||
properties.put(fieldName, prop);
|
||||
|
||||
// Bind ViewModel → Model
|
||||
prop.addListener((_obs, _oldVal, newVal) -> {
|
||||
// use _obs and _oldVal to satisfy static analysis (they are intentionally retained)
|
||||
if (_oldVal != null && _oldVal.equals(newVal)) {
|
||||
// no-op: values unchanged
|
||||
}
|
||||
Method setter = findSetterFor(model.getClass(), fieldName, newVal != null ? newVal.getClass() : null);
|
||||
if (setter != null) {
|
||||
try {
|
||||
setter.invoke(model, newVal);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to invoke setter for field {}", fieldName, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a given method follows the JavaBean getter convention.
|
||||
* The method must be public, take no parameters, have a non-void return type,
|
||||
* and its name should start with either "get" or "is".
|
||||
*
|
||||
* @param method the method to be assessed
|
||||
* @return true if the method adheres to the JavaBean getter convention, false otherwise
|
||||
*/
|
||||
private boolean isGetter(Method method) {
|
||||
return Modifier.isPublic(method.getModifiers())
|
||||
&& method.getParameterCount() == 0
|
||||
&& !method.getReturnType().equals(void.class)
|
||||
&& (method.getName().startsWith("get") || method.getName().startsWith("is"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the field name from a given Java method by following JavaBean naming conventions.
|
||||
* The method's name is analyzed to strip the "get" or "is" prefix (if present),
|
||||
* and the result is converted into a decapitalized field name.
|
||||
*
|
||||
* @param method the method whose name is to be processed to derive the field name
|
||||
* @return the derived field name, or the original method name if no "get" or "is" prefix is found
|
||||
*/
|
||||
private String getFieldName(Method method) {
|
||||
String name = method.getName();
|
||||
if (name.startsWith("get")) {
|
||||
return decapitalize(name.substring(3));
|
||||
} else if (name.startsWith("is")) {
|
||||
return decapitalize(name.substring(2));
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// ========== Hilfsmethoden ==========
|
||||
|
||||
/**
|
||||
* Invokes the specified method on the model object and returns the result.
|
||||
* If the method invocation fails, logs a warning and returns null.
|
||||
*
|
||||
* @param method the method to be invoked, typically a JavaBean getter
|
||||
* @return the result of invoking the method, or null if an error occurs
|
||||
*/
|
||||
private Object invokeGetter(Method method) {
|
||||
try {
|
||||
return method.invoke(model);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to invoke getter: {}", method.getName(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an object into an appropriate JavaFX {@link Property} based on its type.
|
||||
*
|
||||
* @param value the object to be converted into a JavaFX property; can be of type String, Integer,
|
||||
* Boolean, Double, Float, Long, or any other object
|
||||
* @return a JavaFX {@link Property} corresponding to the type of the input object, such as
|
||||
* {@link SimpleStringProperty} for String, {@link SimpleIntegerProperty} for Integer,
|
||||
* {@link SimpleBooleanProperty} for Boolean, and so on. If the type is not explicitly
|
||||
* handled, a {@link SimpleObjectProperty} is returned wrapping the object
|
||||
*/
|
||||
private Property<?> toProperty(Object value) {
|
||||
if (value instanceof String s) return new SimpleStringProperty(s);
|
||||
if (value instanceof Integer i) return new SimpleIntegerProperty(i);
|
||||
if (value instanceof Boolean b) return new SimpleBooleanProperty(b);
|
||||
if (value instanceof Double d) return new SimpleDoubleProperty(d);
|
||||
if (value instanceof Float f) return new SimpleFloatProperty(f);
|
||||
if (value instanceof Long l) return new SimpleLongProperty(l);
|
||||
return new SimpleObjectProperty<>(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a setter method in a specified class that corresponds to the given field name and value type.
|
||||
* <p>
|
||||
* The method searches for a public method in the class whose name matches the JavaBean-style setter
|
||||
* naming convention (e.g., "setFieldName"). The method must accept exactly one parameter, and if a
|
||||
* value type is provided, the parameter type must be assignable from it.
|
||||
*
|
||||
* @param clazz the class to search for the method
|
||||
* @param fieldName the name of the field for which the setter method is being sought
|
||||
* @param valueType the expected type of the parameter for the setter method, or null if no specific
|
||||
* type constraint is required
|
||||
* @return the setter method if found, or null if no matching method is identified
|
||||
*/
|
||||
private Method findSetterFor(Class<?> clazz, String fieldName, Class<?> valueType) {
|
||||
String setterName = "set" + capitalize(fieldName);
|
||||
for (Method m : clazz.getMethods()) {
|
||||
if (m.getName().equals(setterName) && m.getParameterCount() == 1) {
|
||||
if (valueType == null || m.getParameterTypes()[0].isAssignableFrom(valueType)) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the first character of the given string to lowercase, leaving the rest of the string unchanged.
|
||||
* If the input string is null or empty, it is returned as-is.
|
||||
*
|
||||
* @param str the string to be decapitalized
|
||||
* @return the decapitalized string, or the original string if it is null or empty
|
||||
*/
|
||||
private String decapitalize(String str) {
|
||||
if (str == null || str.isEmpty()) return str;
|
||||
return str.substring(0, 1).toLowerCase() + str.substring(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalizes the first letter of the given string and leaves the rest of the string unchanged.
|
||||
* If the input string is null or empty, it is returned as is.
|
||||
*
|
||||
* @param str the string to be capitalized
|
||||
* @return the input string with its first character converted to uppercase, or the original string if it is null or empty
|
||||
*/
|
||||
private String capitalize(String str) {
|
||||
if (str == null || str.isEmpty()) return str;
|
||||
return str.substring(0, 1).toUpperCase() + str.substring(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the JavaFX property associated with the given field name.
|
||||
*
|
||||
* @param name the name of the field whose associated JavaFX property is to be returned
|
||||
* @return the JavaFX {@link Property} associated with the specified field name,
|
||||
* or null if no property exists for the given name
|
||||
*/
|
||||
public Property<?> getProperty(String name) {
|
||||
return properties.get(name);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package de.neitzel.fx.component;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Generic controller used by the ComponentLoader to bind FXML views
|
||||
* to automatically generated ViewModels based on POJO models.
|
||||
* <p>
|
||||
* This controller provides access to the {@link AutoViewModel}
|
||||
* which contains JavaFX properties derived from the model.
|
||||
*/
|
||||
public class ComponentController {
|
||||
|
||||
/**
|
||||
* The automatically generated ViewModel that holds JavaFX properties
|
||||
* for all model fields. It is used to create bindings between
|
||||
* the view and the model.
|
||||
*/
|
||||
@Getter
|
||||
private final AutoViewModel<?> viewModel;
|
||||
|
||||
/**
|
||||
* Constructs a new ComponentController instance with the specified AutoViewModel.
|
||||
*
|
||||
* @param viewModel the AutoViewModel containing JavaFX properties derived from the model,
|
||||
* used for binding the view to the model.
|
||||
*/
|
||||
public ComponentController(AutoViewModel<?> viewModel) {
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
package de.neitzel.fx.component;
|
||||
|
||||
import de.neitzel.fx.component.controls.Binding;
|
||||
import de.neitzel.fx.component.controls.FxmlComponent;
|
||||
import de.neitzel.fx.component.model.BindingData;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* ComponentLoader is responsible for loading JavaFX FXML components and binding
|
||||
* them to automatically generated ViewModels based on simple POJO models.
|
||||
*/
|
||||
@Slf4j
|
||||
public class ComponentLoader {
|
||||
|
||||
/**
|
||||
* The controller object associated with the ComponentLoader.
|
||||
* It is typically used to manage the behavior and interactions of the UI components
|
||||
* defined in an FXML file.
|
||||
*/
|
||||
private Object controller;
|
||||
|
||||
/**
|
||||
* Default constructor only
|
||||
*/
|
||||
public ComponentLoader() {
|
||||
// default constructor only
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an FXML file from the specified URL, sets the provided controller, and processes bindings.
|
||||
* This method parses the FXML file, initializes the specified controller, and evaluates binding controls
|
||||
* defined within the FXML namespace.
|
||||
*
|
||||
* @param <T> the type of the root node, which must extend {@code Parent}
|
||||
* @param fxmlUrl the URL of the FXML file to be loaded
|
||||
* @param controller the controller to associate with the FXML file
|
||||
* @param nothing an unused parameter, included for compatibility
|
||||
* @return the root node loaded from the FXML file
|
||||
* @throws IOException if there is an error reading the FXML file
|
||||
*/
|
||||
public static <T extends Parent> T load(URL fxmlUrl, Object controller, @SuppressWarnings("unused") String nothing) throws IOException {
|
||||
FXMLLoader loader = new FXMLLoader(fxmlUrl);
|
||||
loader.setController(controller);
|
||||
T root = loader.load();
|
||||
|
||||
Map<String, Object> namespace = loader.getNamespace();
|
||||
|
||||
// Nach allen BindingControls suchen:
|
||||
List<Binding> bindingControls = collectAllNodes(root).stream()
|
||||
.filter(n -> n instanceof Binding)
|
||||
.map(n -> (Binding) n)
|
||||
.toList();
|
||||
|
||||
for (Binding bc : bindingControls) {
|
||||
evaluateBindings(bc.getBindings(), namespace);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely binds two properties bidirectionally while ensuring type compatibility.
|
||||
* If the target property's type is not compatible with the source property's type, the binding will
|
||||
* not be performed, and an error will be logged.
|
||||
*
|
||||
* @param source the source property to bind, must not be null
|
||||
* @param target the target property to bind to; the type will be checked for compatibility at runtime
|
||||
* @param <T> the type of the source property's value
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> void bindBidirectionalSafe(@NotNull Property<T> source, Property<?> target) {
|
||||
try {
|
||||
Property<T> targetCasted = (Property<T>) target;
|
||||
source.bindBidirectional(targetCasted);
|
||||
} catch (ClassCastException e) {
|
||||
log.error("⚠️ Typkonflikt beim Binding: {} ⇄ {}", source.getClass(), target.getClass(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to bind the source property to the target property in a type-safe manner.
|
||||
* If the target property's type is incompatible with the source property's type, the binding operation
|
||||
* will be skipped, and an error message will be logged.
|
||||
*
|
||||
* @param source the source property to bind, must not be null
|
||||
* @param target the target property to which the source will be bound
|
||||
* @param <T> the type of the source property's value
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> void bindSafe(@NotNull Property<T> source, Property<?> target) {
|
||||
try {
|
||||
Property<T> targetCasted = (Property<T>) target;
|
||||
source.bind(targetCasted);
|
||||
} catch (ClassCastException e) {
|
||||
log.error("⚠️ Typkonflikt beim Binding: {} ⇄ {}", source.getClass(), target.getClass(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates and establishes bindings between source and target properties based on the provided
|
||||
* bindings list and namespace. Supports unidirectional and bidirectional bindings, ensuring type
|
||||
* compatibility before binding. Logs errors for unsuccessful binding attempts or exceptions.
|
||||
*
|
||||
* @param bindings a list of BindingData objects that define the source, target, and direction for the bindings
|
||||
* @param namespace a map representing the namespace from which expressions in the bindings are resolved
|
||||
*/
|
||||
private static void evaluateBindings(List<BindingData> bindings, Map<String, Object> namespace) {
|
||||
for (var binding : bindings) {
|
||||
try {
|
||||
Object source = resolveExpression(binding.getSource(), namespace);
|
||||
Object target = resolveExpression(binding.getTarget(), namespace);
|
||||
|
||||
if (source instanceof Property<?> sourceProp && target instanceof Property<?> targetProp) {
|
||||
|
||||
Class<?> sourceType = getPropertyType(sourceProp);
|
||||
Class<?> targetType = getPropertyType(targetProp);
|
||||
|
||||
boolean bindableForward = targetType.isAssignableFrom(sourceType);
|
||||
boolean bindableBackward = sourceType.isAssignableFrom(targetType);
|
||||
|
||||
switch (binding.getDirection().toLowerCase()) {
|
||||
case "bidirectional":
|
||||
if (bindableForward && bindableBackward) {
|
||||
bindBidirectionalSafe(sourceProp, targetProp);
|
||||
} else {
|
||||
log.error("⚠️ Kann bidirektionales Binding nicht durchführen: Typen inkompatibel ({} ⇄ {})", sourceType, targetType);
|
||||
}
|
||||
break;
|
||||
case "unidirectional":
|
||||
default:
|
||||
if (bindableForward) {
|
||||
bindSafe(sourceProp, targetProp);
|
||||
} else {
|
||||
log.error("⚠️ Kann unidirektionales Binding nicht durchführen: {} → {} nicht zuweisbar", sourceType, targetType);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Fehler beim Binding: {} → {}", binding.getSource(), binding.getTarget(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the type of the value held by the specified property.
|
||||
*
|
||||
* @param prop the property whose value type is to be determined, must not be null
|
||||
* @return the class of the property's value type; returns Object.class if the type cannot be determined
|
||||
*/
|
||||
private static Class<?> getPropertyType(Property<?> prop) {
|
||||
try {
|
||||
Method getter = prop.getClass().getMethod("get");
|
||||
return getter.getReturnType();
|
||||
} catch (Exception e) {
|
||||
return Object.class; // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a dot-separated expression (e.g., "viewModel.username") by navigating through the provided namespace
|
||||
* map and invoking the corresponding getter methods to access nested properties.
|
||||
*
|
||||
* @param expr the dot-separated expression to resolve, must not be null
|
||||
* @param namespace a map containing the initial objects to resolve the expression from, must not be null
|
||||
* @return the value resolved from the specified expression
|
||||
* @throws Exception if an error occurs while invoking getter methods or resolving the expression
|
||||
*/
|
||||
private static Object resolveExpression(@NotNull String expr, @NotNull Map<String, Object> namespace) throws Exception {
|
||||
// z.B. "viewModel.username"
|
||||
String[] parts = expr.split("\\.");
|
||||
Object current = namespace.get(parts[0]);
|
||||
for (int i = 1; i < parts.length; i++) {
|
||||
String getter = "get" + Character.toUpperCase(parts[i].charAt(0)) + parts[i].substring(1);
|
||||
current = current.getClass().getMethod(getter).invoke(current);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects all nodes in a scene graph starting from the given root node.
|
||||
*
|
||||
* @param root the starting node from which all descendant nodes will be collected
|
||||
* @return a list containing all nodes, including the root node and its descendants
|
||||
*/
|
||||
private static @NotNull List<Node> collectAllNodes(Node root) {
|
||||
List<Node> nodes = new ArrayList<>();
|
||||
nodes.add(root);
|
||||
if (root instanceof Parent parent && !(root instanceof FxmlComponent)) {
|
||||
for (Node child : parent.getChildrenUnmodifiable()) {
|
||||
nodes.addAll(collectAllNodes(child));
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the controller associated with the ComponentLoader.
|
||||
*
|
||||
* @return the controller object
|
||||
*/
|
||||
public Object getController() {
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an FXML file and returns the root JavaFX node without binding to a data model.
|
||||
* This method uses the given {@code fxmlPath} to locate and load the FXML file.
|
||||
*
|
||||
* @param fxmlPath the URL of the FXML file to be loaded
|
||||
* @return the root JavaFX node loaded from the specified FXML file
|
||||
*/
|
||||
public Parent load(URL fxmlPath) {
|
||||
return load(null, fxmlPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an FXML file and binds its elements to a generated ViewModel
|
||||
* based on the given POJO model and specified FXML path.
|
||||
*
|
||||
* @param model the data model (POJO) to bind to the UI
|
||||
* @param fxmlPath the URL path to the FXML file
|
||||
* @return the root JavaFX node loaded from FXML
|
||||
* @throws RuntimeException if the FXML could not be loaded
|
||||
*/
|
||||
public Parent load(Object model, URL fxmlPath) {
|
||||
try {
|
||||
AutoViewModel<?> viewModel = new AutoViewModel<>(model);
|
||||
FXMLLoader loader = new FXMLLoader(fxmlPath);
|
||||
loader.setControllerFactory(type -> {
|
||||
Objects.requireNonNull(type);
|
||||
return new ComponentController(viewModel);
|
||||
});
|
||||
Parent root = loader.load();
|
||||
controller = loader.getController();
|
||||
return root;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("unable to load fxml: " + fxmlPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an FXML file and returns its root node.
|
||||
*
|
||||
* @param fxmlPath the relative path to the FXML file
|
||||
* @return the root JavaFX node loaded from the FXML file
|
||||
*/
|
||||
public Parent load(String fxmlPath) {
|
||||
return load(null, fxmlPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an FXML file and binds its elements to a generated ViewModel
|
||||
* based on the given POJO model.
|
||||
*
|
||||
* @param model the data model (POJO) to bind to the UI
|
||||
* @param fxmlPath the path to the FXML file
|
||||
* @return the root JavaFX node loaded from FXML
|
||||
*/
|
||||
public Parent load(Object model, String fxmlPath) {
|
||||
return load(model, getClass().getResource(fxmlPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a JavaFX UI control to a ViewModel property according to the specified direction.
|
||||
*
|
||||
* @param node the UI node (e.g., TextField)
|
||||
* @param vmProp the ViewModel property to bind to
|
||||
* @param direction the direction of the binding (e.g., "bidirectional", "read")
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
private void bindNodeToProperty(javafx.scene.Node node, Property<?> vmProp, String direction) {
|
||||
if (node instanceof javafx.scene.control.TextField tf && vmProp instanceof javafx.beans.property.StringProperty sp) {
|
||||
if ("bidirectional".equalsIgnoreCase(direction)) {
|
||||
tf.textProperty().bindBidirectional(sp);
|
||||
} else if ("read".equalsIgnoreCase(direction)) {
|
||||
tf.textProperty().bind(sp);
|
||||
}
|
||||
}
|
||||
// Additional control types (e.g., CheckBox, ComboBox) can be added here
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package de.neitzel.fx.component.controls;
|
||||
|
||||
import de.neitzel.fx.component.model.BindingData;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.layout.Region;
|
||||
|
||||
/**
|
||||
* The BindingControl class represents a UI control that manages a list
|
||||
* of {@link BindingData} objects. It extends the {@link Region} class and
|
||||
* provides functionality to bind and monitor connections between source
|
||||
* and target properties.
|
||||
* <p>
|
||||
* The primary purpose of this control is to maintain an observable list
|
||||
* of bindings, allowing developers to track or adjust the linked properties
|
||||
* dynamically.
|
||||
* <p>
|
||||
* The internal list of bindings is implemented as an {@link ObservableList},
|
||||
* allowing property change notifications to be easily monitored for UI
|
||||
* updates or other reactive behaviors.
|
||||
* <p>
|
||||
* This class serves as an organizational component and does not provide
|
||||
* any user interaction by default.
|
||||
*/
|
||||
public class Binding extends Region {
|
||||
|
||||
/**
|
||||
* Represents an observable list of {@link BindingData} objects contained within the
|
||||
* {@link Binding} instance. This list is utilized to manage and monitor
|
||||
* the bindings between source and target properties dynamically.
|
||||
* <p>
|
||||
* The list is implemented as an {@link ObservableList}, allowing changes in the
|
||||
* collection to be observed and reacted to, such as triggering UI updates or
|
||||
* responding to binding modifications.
|
||||
* <p>
|
||||
* This field is initialized as an empty list using {@link FXCollections#observableArrayList()}.
|
||||
* It is declared as final to ensure its reference cannot be changed, while the
|
||||
* contents of the list remain mutable.
|
||||
*/
|
||||
private final ObservableList<BindingData> bindings = FXCollections.observableArrayList();
|
||||
|
||||
/**
|
||||
* Constructs a new instance of the BindingControl class.
|
||||
* <p>
|
||||
* This default constructor initializes the BindingControl without
|
||||
* any pre-configured bindings. The instance will contain an empty
|
||||
* {@link ObservableList} of {@link BindingData} objects, which can later
|
||||
* be populated as needed.
|
||||
* <p>
|
||||
* The constructor does not perform additional setup or initialization,
|
||||
* allowing the class to be extended or customized as necessary.
|
||||
*/
|
||||
public Binding() {
|
||||
// Empty, the ComponentLoader is used to work on the bindings.
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the observable list of {@code Binding} objects associated with this control.
|
||||
* The returned list allows monitoring and management of the bindings maintained
|
||||
* by the {@code BindingControl}.
|
||||
*
|
||||
* @return an {@code ObservableList<Binding>} containing the bindings managed by this control
|
||||
*/
|
||||
public ObservableList<BindingData> getBindings() {
|
||||
return bindings;
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package de.neitzel.fx.component.controls;
|
||||
|
||||
import de.neitzel.fx.component.ComponentLoader;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* A reusable JavaFX component that loads and embeds an FXML-defined UI fragment.
|
||||
* <p>
|
||||
* The component exposes three properties:
|
||||
* <ul>
|
||||
* <li>fxml: the resource path to the FXML file to load (relative to the component class)</li>
|
||||
* <li>direction: a textual hint that describes binding directionality (default "unidirectional")</li>
|
||||
* <li>data: an arbitrary object that will be injected into the loaded controller if a suitable setter exists</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* When the {@code fxml} property changes the FXML is loaded via {@link ComponentLoader}. After loading the
|
||||
* controller instance available from the loader, this component attempts to inject the object from the
|
||||
* {@code data} property by calling a matching {@code setData(...)} method via reflection.
|
||||
*/
|
||||
public class FxmlComponent extends StackPane {
|
||||
|
||||
private final StringProperty fxml = new SimpleStringProperty();
|
||||
|
||||
private final StringProperty direction = new SimpleStringProperty("unidirectional");
|
||||
|
||||
private final ObjectProperty<Object> data = new SimpleObjectProperty<>();
|
||||
|
||||
/**
|
||||
* Creates a new instance of {@code FxmlComponent}.
|
||||
* <p>
|
||||
* The constructor registers listeners on the {@code fxml} and {@code data} properties to
|
||||
* perform lazy loading and data injection when values change.
|
||||
*/
|
||||
public FxmlComponent() {
|
||||
fxml.addListener((obs, oldVal, newVal) -> load());
|
||||
data.addListener((obs, oldVal, newVal) -> injectData());
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the FXML specified by the {@link #fxml} property and replaces the current children
|
||||
* with the loaded root node. If no FXML is configured or the path is blank, the method returns
|
||||
* without performing any action.
|
||||
*/
|
||||
private void load() {
|
||||
if (getFxml() == null || getFxml().isBlank()) return;
|
||||
ComponentLoader loader = new ComponentLoader();
|
||||
Parent content = loader.load(getClass().getResource(getFxml()));
|
||||
|
||||
getChildren().setAll(content);
|
||||
|
||||
Object controller = loader.getController();
|
||||
if (controller != null && getData() != null) {
|
||||
injectDataToController(controller, getData());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to inject the {@link #data} object into the controller of the currently loaded child node,
|
||||
* if a controller is present and defines a single-argument method named {@code setData}.
|
||||
*/
|
||||
private void injectData() {
|
||||
if (!getChildren().isEmpty() && getData() != null) {
|
||||
Object controller = getControllerFromChild(getChildren().get(0));
|
||||
if (controller != null) {
|
||||
injectDataToController(controller, getData());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured FXML resource path.
|
||||
*
|
||||
* @return the FXML path or {@code null} if not set
|
||||
*/
|
||||
public String getFxml() {
|
||||
return fxml.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the FXML resource path to load. Changing this value triggers a reload of the component content.
|
||||
*
|
||||
* @param fxml resource path relative to the component class
|
||||
*/
|
||||
public void setFxml(String fxml) {
|
||||
this.fxml.set(fxml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data object that will be injected into the loaded controller when available.
|
||||
*
|
||||
* @return the data object or {@code null}
|
||||
*/
|
||||
public Object getData() {
|
||||
return data.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the data object to be injected into the controller of the loaded FXML.
|
||||
* Changing the value will attempt injection into the currently loaded controller.
|
||||
*
|
||||
* @param data the data object to inject
|
||||
*/
|
||||
public void setData(Object data) {
|
||||
this.data.set(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses reflection to find a single-argument method named {@code setData} on the controller and
|
||||
* invokes it with the provided {@code dataObject}.
|
||||
*
|
||||
* @param controller the controller instance to inject into
|
||||
* @param dataObject the object to inject
|
||||
*/
|
||||
private void injectDataToController(Object controller, Object dataObject) {
|
||||
// Daten-Objekt per Reflection zuweisen
|
||||
// Beispiel: Controller hat `setData(User data)`
|
||||
Arrays.stream(controller.getClass().getMethods())
|
||||
.filter(m -> m.getName().equals("setData") && m.getParameterCount() == 1)
|
||||
.findFirst()
|
||||
.ifPresent(method -> {
|
||||
try {
|
||||
method.invoke(controller, dataObject);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
|
||||
// Optional: automatisch Binding ausführen (s. u.)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the controller instance from a child node's properties map if present.
|
||||
*
|
||||
* @param node the child node to inspect
|
||||
* @return the controller object stored under the property key {@code "fx:controller"} or {@code null}
|
||||
*/
|
||||
private Object getControllerFromChild(Node node) {
|
||||
if (node.getProperties().containsKey("fx:controller")) {
|
||||
return node.getProperties().get("fx:controller");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JavaFX property representing the configured FXML path.
|
||||
*
|
||||
* @return the FXML property
|
||||
*/
|
||||
public StringProperty fxmlProperty() {
|
||||
return fxml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JavaFX property representing the binding direction hint.
|
||||
*
|
||||
* @return the direction property
|
||||
*/
|
||||
public StringProperty directionProperty() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured direction string.
|
||||
*
|
||||
* @return the direction hint (e.g., "unidirectional")
|
||||
*/
|
||||
public String getDirection() {
|
||||
return direction.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the direction hint used by higher-level binding logic.
|
||||
*
|
||||
* @param direction textual direction hint
|
||||
*/
|
||||
public void setDirection(String direction) {
|
||||
this.direction.set(direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JavaFX property representing the data object used for injection.
|
||||
*
|
||||
* @return the data property
|
||||
*/
|
||||
public ObjectProperty<Object> dataProperty() {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package de.neitzel.fx.component.model;
|
||||
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
|
||||
/**
|
||||
* The Binding class represents a connection between a source and a target,
|
||||
* with an associated direction. It is typically used to define a binding
|
||||
* relationship that determines how data flows between these two entities.
|
||||
* <p>
|
||||
* The class provides three properties:
|
||||
* - direction: Represents the type of the binding. Defaults to "unidirectional".
|
||||
* - source: Represents the source of the binding.
|
||||
* - target: Represents the target of the binding.
|
||||
* <p>
|
||||
* It uses JavaFX property types, allowing these properties to be observed
|
||||
* for changes.
|
||||
*/
|
||||
public class BindingData {
|
||||
|
||||
/**
|
||||
* Represents the direction of the binding in the {@code Binding} class.
|
||||
* It determines whether the binding is unidirectional or bidirectional.
|
||||
* The default value is "unidirectional".
|
||||
* <p>
|
||||
* This property is observed for changes, enabling dynamic updates
|
||||
* within the JavaFX property system.
|
||||
*/
|
||||
private StringProperty direction = new SimpleStringProperty("unidirectional");
|
||||
|
||||
/**
|
||||
* Represents the source of the binding. This property holds a string value
|
||||
* that specifies the originating object or identifier in the binding connection.
|
||||
* It can be observed for changes, allowing updates to the binding relationship
|
||||
* when the source value is modified.
|
||||
*/
|
||||
private StringProperty source = new SimpleStringProperty();
|
||||
|
||||
/**
|
||||
* Represents the target of the binding in the Binding class.
|
||||
* This property holds the target value, which can be observed for changes.
|
||||
*/
|
||||
private StringProperty target = new SimpleStringProperty();
|
||||
|
||||
/**
|
||||
* Default constructor only
|
||||
*/
|
||||
public BindingData() {
|
||||
// default constructor only
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current direction of the binding.
|
||||
* The direction determines how data flows between
|
||||
* the source and the target, and typically defaults to "unidirectional".
|
||||
*
|
||||
* @return the current direction of the binding
|
||||
*/
|
||||
public String getDirection() {
|
||||
return direction.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the direction of the binding.
|
||||
*
|
||||
* @param dir the new direction to set for the binding
|
||||
*/
|
||||
public void setDirection(String dir) {
|
||||
direction.set(dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current source value of the binding.
|
||||
*
|
||||
* @return the source value as a String.
|
||||
*/
|
||||
public String getSource() {
|
||||
return source.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of the source property for this binding.
|
||||
*
|
||||
* @param source the new source value to be set
|
||||
*/
|
||||
public void setSource(String source) {
|
||||
this.source.set(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current value of the target property.
|
||||
*
|
||||
* @return the value of the target property as a String.
|
||||
*/
|
||||
public String getTarget() {
|
||||
return target.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the target property of the binding.
|
||||
*
|
||||
* @param target the new value to set for the target property
|
||||
*/
|
||||
public void setTarget(String target) {
|
||||
this.target.set(target);
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package de.neitzel.fx.injectfx;
|
||||
|
||||
import de.neitzel.injection.ComponentData;
|
||||
import de.neitzel.injection.ComponentScanner;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Manages the creation and storage of singleton instances for all @FXMLComponent classes.
|
||||
* It ensures that each component is instantiated only once and resolves dependencies recursively.
|
||||
*/
|
||||
public class FXMLComponentInstances {
|
||||
|
||||
@Getter
|
||||
/** Map holding instances of all @FXMLComponent classes, indexed by class and its unique superclasses/interfaces. */
|
||||
private final Map<Class<?>, Object> instanceMap = new HashMap<>();
|
||||
|
||||
/**
|
||||
* The InjectableComponents instance that provides information about instantiable components.
|
||||
*/
|
||||
private final ComponentScanner injectableScanner;
|
||||
|
||||
/**
|
||||
* Constructs an FXMLComponentInstances manager and initializes all component instances.
|
||||
*
|
||||
* @param injectableScanner The InjectableComponents instance containing resolved component types.
|
||||
*/
|
||||
public FXMLComponentInstances(ComponentScanner injectableScanner) {
|
||||
this.injectableScanner = injectableScanner;
|
||||
createAllInstances();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates instances for all registered @FXMLComponent classes.
|
||||
*/
|
||||
private void createAllInstances() {
|
||||
for (Class<?> componentClass : injectableScanner.getInstantiableComponents().keySet()) {
|
||||
getInstance(componentClass); // Ensures each component is instantiated once
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an instance of a given component class, creating it if necessary.
|
||||
*
|
||||
* @param componentClass The class for which an instance is needed.
|
||||
* @return An instance of the requested class.
|
||||
*/
|
||||
public Object getInstance(Class<?> componentClass) {
|
||||
if (instanceMap.containsKey(componentClass)) {
|
||||
return instanceMap.get(componentClass);
|
||||
}
|
||||
|
||||
Class<?> concreteClass = injectableScanner.getInstantiableComponents().get(componentClass).getType();
|
||||
if (concreteClass == null) {
|
||||
throw new IllegalStateException("No concrete implementation found for: " + componentClass.getName());
|
||||
}
|
||||
|
||||
Object instance = createInstance(concreteClass);
|
||||
registerInstance(concreteClass, instance);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of the given class by selecting the best constructor.
|
||||
*
|
||||
* @param concreteClass The class to instantiate.
|
||||
* @return A new instance of the class.
|
||||
*/
|
||||
private Object createInstance(Class<?> concreteClass) {
|
||||
try {
|
||||
Constructor<?> bestConstructor = findBestConstructor(concreteClass);
|
||||
if (bestConstructor == null) {
|
||||
throw new IllegalStateException("No suitable constructor found for: " + concreteClass.getName());
|
||||
}
|
||||
|
||||
Class<?>[] paramTypes = bestConstructor.getParameterTypes();
|
||||
Object[] params = Arrays.stream(paramTypes)
|
||||
.map(this::getInstance) // Recursively resolve dependencies
|
||||
.toArray();
|
||||
|
||||
return bestConstructor.newInstance(params);
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to instantiate " + concreteClass.getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the best constructor for a given class by checking which one has resolvable parameters.
|
||||
*
|
||||
* @param concreteClass The class to analyze.
|
||||
* @return The best constructor or {@code null} if none can be used.
|
||||
*/
|
||||
private Constructor<?> findBestConstructor(Class<?> concreteClass) {
|
||||
Constructor<?>[] constructors = concreteClass.getConstructors();
|
||||
|
||||
// Prefer constructors with all parameters resolvable
|
||||
for (Constructor<?> constructor : constructors) {
|
||||
if (canUseConstructor(constructor)) {
|
||||
return constructor;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a given constructor can be used by ensuring all parameters are known.
|
||||
*
|
||||
* @param constructor The constructor to check.
|
||||
* @return {@code true} if all parameters can be resolved, otherwise {@code false}.
|
||||
*/
|
||||
private boolean canUseConstructor(Constructor<?> constructor) {
|
||||
return Arrays.stream(constructor.getParameterTypes())
|
||||
.allMatch(injectableScanner.getInstantiableComponents()::containsKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an instance in the map for all unique interfaces and superclasses it implements.
|
||||
*
|
||||
* @param concreteClass The concrete component class.
|
||||
* @param instance The instance to store.
|
||||
*/
|
||||
private void registerInstance(Class<?> concreteClass, Object instance) {
|
||||
instanceMap.put(concreteClass, instance);
|
||||
|
||||
for (Map.Entry<Class<?>, ComponentData> entry : injectableScanner.getInstantiableComponents().entrySet()) {
|
||||
if (entry.getValue().getType().equals(concreteClass)) {
|
||||
instanceMap.put(entry.getKey(), instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package de.neitzel.fx.injectfx;
|
||||
|
||||
import javafx.util.Callback;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* The InjectingControllerFactory is responsible for creating controller instances for JavaFX that
|
||||
* support dependency injection. It uses a parameter map to resolve and supply dependencies to
|
||||
* controller constructors dynamically during instantiation.
|
||||
*
|
||||
* <p>This class analyzes available constructors of a requested controller class at runtime and
|
||||
* selects the first constructor whose parameter types are all present in the internal parameter map.
|
||||
* The corresponding instances are retrieved and passed to the constructor to create the controller.
|
||||
*
|
||||
* <p>It implements the {@link javafx.util.Callback} interface to integrate with the JavaFX
|
||||
* {@link javafx.fxml.FXMLLoader} controller factory mechanism.
|
||||
*/
|
||||
public class InjectingControllerFactory implements Callback<Class<?>, Object> {
|
||||
|
||||
/**
|
||||
* A map that stores class-to-object mappings used for dependency injection
|
||||
* in controller instantiation. Each key represents a parameter type and the
|
||||
* associated value is the instance to be injected for that type.
|
||||
*/
|
||||
private final Map<Class<?>, Object> parameterMap = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Default constructor only
|
||||
*/
|
||||
public InjectingControllerFactory() {
|
||||
// default constructor only
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a mapping between a class and its corresponding object instance
|
||||
* to the parameter map used for dependency injection.
|
||||
*
|
||||
* @param clazz The class type to be associated with the provided object instance.
|
||||
* @param object The object instance to be injected for the specified class type.
|
||||
*/
|
||||
public void addInjectingData(Class<?> clazz, Object object) {
|
||||
parameterMap.put(clazz, object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an instance of a controller class using a constructor that matches the dependencies
|
||||
* defined in the parameter map. The method dynamically analyzes the constructors of the given
|
||||
* class and attempts to instantiate the class by injecting required dependencies.
|
||||
*
|
||||
* @param controllerClass the class of the controller to be instantiated
|
||||
* @return an instance of the specified controller class
|
||||
* @throws RuntimeException if an error occurs while creating the controller instance or if no suitable constructor is found
|
||||
*/
|
||||
@Override
|
||||
public Object call(Class<?> controllerClass) {
|
||||
try {
|
||||
Optional<Constructor<?>> bestConstructor = Stream.of(controllerClass.getConstructors())
|
||||
.filter(constructor -> canBeInstantiated(constructor, parameterMap))
|
||||
.findFirst();
|
||||
|
||||
if (bestConstructor.isPresent()) {
|
||||
Constructor<?> constructor = bestConstructor.get();
|
||||
Object[] parameters = Stream.of(constructor.getParameterTypes())
|
||||
.map(parameterMap::get)
|
||||
.toArray();
|
||||
|
||||
return constructor.newInstance(parameters);
|
||||
} else {
|
||||
throw new IllegalStateException("Kein passender Konstruktor gefunden für " + controllerClass.getName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Fehler beim Erstellen der Controller-Instanz für " + controllerClass.getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a given constructor can be instantiated using the provided parameter map.
|
||||
* This method checks if the parameter map contains entries for all parameter types required
|
||||
* by the specified constructor.
|
||||
*
|
||||
* @param constructor The constructor to be evaluated for instantiability.
|
||||
* @param parameterMap A map where keys are parameter types and values are the corresponding
|
||||
* instances available for injection.
|
||||
* @return {@code true} if the constructor can be instantiated with the given parameter map;
|
||||
* {@code false} otherwise.
|
||||
*/
|
||||
private boolean canBeInstantiated(Constructor<?> constructor, Map<Class<?>, Object> parameterMap) {
|
||||
return Stream.of(constructor.getParameterTypes()).allMatch(parameterMap::containsKey);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package de.neitzel.fx.injectfx;
|
||||
|
||||
import de.neitzel.injection.ComponentScanner;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
|
||||
/**
|
||||
* The InjectingFXMLLoader class provides a custom implementation of JavaFX's FXMLLoader
|
||||
* that supports dependency injection. It facilitates the loading of FXML files while
|
||||
* dynamically injecting dependencies into controllers during the loading process.
|
||||
* <p>
|
||||
* This class utilizes the InjectingControllerFactory to enable seamless integration
|
||||
* of dependency injection with JavaFX controllers. Dependencies can be added to the
|
||||
* controller factory, and the loader will use this factory to instantiate controllers
|
||||
* with the appropriate dependencies.
|
||||
* <p>
|
||||
* Features of this loader include:
|
||||
* - Support for dependency injection into JavaFX controllers by using a custom factory.
|
||||
* - Adding custom dependency mappings at runtime.
|
||||
* - Scanning and initializing injectable components from a specified package.
|
||||
*/
|
||||
@Slf4j
|
||||
public class InjectingFXMLLoader {
|
||||
|
||||
/**
|
||||
* Represents an instance of the {@link InjectingControllerFactory}, which is used for creating
|
||||
* and managing controller instances with dependency injection capabilities in a JavaFX application.
|
||||
* <p>
|
||||
* This factory facilitates the injection of dependencies by dynamically resolving and supplying
|
||||
* required objects during controller instantiation. It plays a critical role in enabling seamless
|
||||
* integration of dependency injection with JavaFX's {@link FXMLLoader}.
|
||||
* <p>
|
||||
* The `controllerFactory` is used internally by the {@link InjectingFXMLLoader} to provide a consistent
|
||||
* and extensible mechanism for controller creation while maintaining loose coupling and enhancing testability.
|
||||
* <p>
|
||||
* Key responsibilities:
|
||||
* - Manages a mapping of classes to their injectable instances required for controller instantiation.
|
||||
* - Dynamically analyzes and invokes appropriate constructors for controllers based on the availability
|
||||
* of dependencies.
|
||||
* - Ensures that controllers are created with required dependencies, preventing manual resolution of injections.
|
||||
* <p>
|
||||
* This variable is initialized in the {@link InjectingFXMLLoader} and can be extended with additional
|
||||
* mappings at runtime using relevant methods.
|
||||
*/
|
||||
private final InjectingControllerFactory controllerFactory;
|
||||
|
||||
/**
|
||||
* Default constructor for the InjectingFXMLLoader class.
|
||||
* This initializes the loader with a new instance of the {@link InjectingControllerFactory},
|
||||
* which is used to provide dependency injection capabilities for JavaFX controllers.
|
||||
* <p>
|
||||
* The {@link InjectingControllerFactory} allows for the registration and dynamic injection
|
||||
* of dependencies into controllers when they are instantiated during the FXML loading process.
|
||||
*/
|
||||
public InjectingFXMLLoader() {
|
||||
controllerFactory = new InjectingControllerFactory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new instance of the InjectingFXMLLoader class with a specified
|
||||
* InjectingControllerFactory for dependency injection. This enables the FXMLLoader
|
||||
* to use the provided controller factory to instantiate controllers with their
|
||||
* required dependencies during the FXML loading process.
|
||||
*
|
||||
* @param controllerFactory The controller factory responsible for creating controller
|
||||
* instances with dependency injection support.
|
||||
*/
|
||||
public InjectingFXMLLoader(InjectingControllerFactory controllerFactory) {
|
||||
this.controllerFactory = controllerFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an instance of `InjectingFXMLLoader` and initializes the component scanner
|
||||
* for dependency injection based on components found within a specified package.
|
||||
* The scanned components are analyzed and their instances are prepared for injection
|
||||
* using the internal `InjectingControllerFactory`.
|
||||
*
|
||||
* @param packageName The package name to be scanned for injectable components.
|
||||
* Classes within the specified package will be identified
|
||||
* as potential dependencies and made available for injection
|
||||
* into JavaFX controllers during FXML loading.
|
||||
*/
|
||||
public InjectingFXMLLoader(String packageName) {
|
||||
controllerFactory = new InjectingControllerFactory();
|
||||
ComponentScanner scanner = new ComponentScanner(packageName);
|
||||
FXMLComponentInstances instances = new FXMLComponentInstances(scanner);
|
||||
addInjectingData(instances);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds all injectable data from the given {@code FXMLComponentInstances} to the controller factory.
|
||||
* Iterates through the classes in the instance map and delegates adding each class-instance pair
|
||||
* to the {@link #addInjectingData(Class, Object)} method.
|
||||
*
|
||||
* @param instances An {@code FXMLComponentInstances} object containing the mapping of classes
|
||||
* to their respective singleton instances. This data represents the components
|
||||
* available for dependency injection.
|
||||
*/
|
||||
private void addInjectingData(FXMLComponentInstances instances) {
|
||||
for (var clazz : instances.getInstanceMap().keySet()) {
|
||||
addInjectingData(clazz, instances.getInstance(clazz));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a specific class-object mapping to the controller factory for dependency injection.
|
||||
* This method allows the association of a given class type with its corresponding instance,
|
||||
* enabling dependency injection into controllers during the FXML loading process.
|
||||
*
|
||||
* @param clazz The class type to be associated with the provided object instance.
|
||||
* @param object The object instance to be injected for the specified class type.
|
||||
*/
|
||||
public void addInjectingData(Class<?> clazz, Object object) {
|
||||
controllerFactory.addInjectingData(clazz, object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an FXML resource from the provided URL and injects dependencies into its controller
|
||||
* using the configured {@code InjectingControllerFactory}.
|
||||
*
|
||||
* @param url the URL of the FXML file to be loaded
|
||||
* @param <T> the type of the root element defined in the FXML
|
||||
* @return the root element of the FXML file
|
||||
* @throws IOException if an error occurs during the loading of the FXML file
|
||||
*/
|
||||
public <T> T load(URL url) throws IOException {
|
||||
FXMLLoader loader = new FXMLLoader(url);
|
||||
loader.setControllerFactory(controllerFactory);
|
||||
return loader.load();
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package de.neitzel.fx.mvvm;
|
||||
|
||||
/**
|
||||
* Enum representing the direction of data binding between a JavaFX control and a ViewModel property.
|
||||
* - READ: one-way binding from ViewModel to Control.
|
||||
* - WRITE: one-way binding from Control to ViewModel.
|
||||
* - BIDIRECTIONAL: two-way binding between Control and ViewModel.
|
||||
*/
|
||||
public enum BindDirection {
|
||||
/**
|
||||
* One-way binding from the ViewModel to the JavaFX control.
|
||||
* The control reflects changes in the ViewModel but does not update it.
|
||||
*/
|
||||
READ,
|
||||
|
||||
/**
|
||||
* One-way binding from the JavaFX control to the ViewModel.
|
||||
* The ViewModel is updated when the control changes, but not vice versa.
|
||||
*/
|
||||
WRITE,
|
||||
|
||||
/**
|
||||
* Two-way binding between the JavaFX control and the ViewModel.
|
||||
* Changes in either are reflected in the other.
|
||||
*/
|
||||
BIDIRECTIONAL;
|
||||
|
||||
/**
|
||||
* Parses a string to determine the corresponding BindDirection.
|
||||
* Defaults to BIDIRECTIONAL if the input is null or empty.
|
||||
*
|
||||
* @param value the string representation of the direction
|
||||
* @return the corresponding BindDirection enum constant
|
||||
* @throws IllegalArgumentException if the string does not match any valid direction
|
||||
*/
|
||||
public static BindDirection fromString(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return BIDIRECTIONAL;
|
||||
}
|
||||
|
||||
for (BindDirection direction : BindDirection.values()) {
|
||||
if (direction.name().equalsIgnoreCase(value)) {
|
||||
return direction;
|
||||
}
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Not a valid direction: " + value);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package de.neitzel.fx.mvvm;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.control.TextInputControl;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Custom FXMLLoader that binds JavaFX controls to a GenericViewModel using metadata from FXML.
|
||||
* It supports automatic binding setup based on properties defined in the FXML's node properties.
|
||||
*
|
||||
* @param <T> the type of the model used in the ViewModel
|
||||
*/
|
||||
public class BindingAwareFXMLLoader<T> {
|
||||
|
||||
/**
|
||||
* The model instance used to construct the GenericViewModel.
|
||||
*/
|
||||
private final T model;
|
||||
|
||||
/**
|
||||
* Constructs a new instance of the BindingAwareFXMLLoader class.
|
||||
* This loader is designed to load FXML files and automatically bind UI elements
|
||||
* to the properties of the provided model using a binding-aware approach.
|
||||
*
|
||||
* @param model the model object to be used for binding to the UI components
|
||||
*/
|
||||
public BindingAwareFXMLLoader(T model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an FXML file and performs automatic binding setup using the GenericViewModel.
|
||||
*
|
||||
* @param fxml the input stream of the FXML file
|
||||
* @return the root node of the loaded scene graph
|
||||
* @throws Exception if loading the FXML or binding fails
|
||||
*/
|
||||
public Parent load(InputStream fxml) throws Exception {
|
||||
FXMLLoader loader = new FXMLLoader();
|
||||
loader.setControllerFactory(param -> {
|
||||
GenericViewController<T> controller = new GenericViewController<>();
|
||||
controller.setModel(model);
|
||||
return controller;
|
||||
});
|
||||
|
||||
Parent root = loader.load(fxml);
|
||||
|
||||
GenericViewController<T> controller = loader.getController();
|
||||
GenericViewModel<T> viewModel = controller.getViewModel();
|
||||
|
||||
// Traverse Nodes and evaluate binding info
|
||||
bindNodesRecursively(root, viewModel);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively traverses the scene graph and binds controls to properties
|
||||
* in the ViewModel based on custom metadata in the node properties map.
|
||||
*
|
||||
* @param node the current node to inspect
|
||||
* @param viewModel the ViewModel holding the properties
|
||||
*/
|
||||
private void bindNodesRecursively(Node node, GenericViewModel<T> viewModel) {
|
||||
if (node instanceof Parent parent) {
|
||||
for (Node child : parent.getChildrenUnmodifiable()) {
|
||||
bindNodesRecursively(child, viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
Object userData = node.getProperties().get("bind:property");
|
||||
if (userData instanceof String propertyName) {
|
||||
BindDirection direction = getDirection(node);
|
||||
|
||||
switch (node) {
|
||||
case TextInputControl control -> {
|
||||
Property<String> prop = viewModel.property(StringProperty.class, propertyName);
|
||||
bind(control.textProperty(), prop, direction);
|
||||
}
|
||||
case javafx.scene.control.Label label -> {
|
||||
Property<String> prop = viewModel.property(StringProperty.class, propertyName);
|
||||
bind(label.textProperty(), prop, direction);
|
||||
}
|
||||
case javafx.scene.control.CheckBox checkBox -> {
|
||||
Property<Boolean> prop = viewModel.property(javafx.beans.property.BooleanProperty.class, propertyName);
|
||||
bind(checkBox.selectedProperty(), prop, direction);
|
||||
}
|
||||
case javafx.scene.control.Slider slider -> {
|
||||
Property<Number> prop = viewModel.property(javafx.beans.property.DoubleProperty.class, propertyName);
|
||||
bind(slider.valueProperty(), prop, direction);
|
||||
}
|
||||
case javafx.scene.control.DatePicker datePicker -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
Property<java.time.LocalDate> prop =
|
||||
(Property<java.time.LocalDate>) viewModel.property(
|
||||
javafx.beans.property.ObjectProperty.class,
|
||||
propertyName
|
||||
);
|
||||
bind(datePicker.valueProperty(), prop, direction);
|
||||
}
|
||||
default -> {
|
||||
// ignore unsupported nodes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds two JavaFX properties according to the specified binding direction.
|
||||
*
|
||||
* @param controlProperty the property of the control (e.g. TextField.textProperty)
|
||||
* @param modelProperty the property from the ViewModel
|
||||
* @param direction the direction of the binding
|
||||
* @param <V> the value type of the property
|
||||
*/
|
||||
private <V> void bind(Property<V> controlProperty, Property<V> modelProperty, BindDirection direction) {
|
||||
switch (direction) {
|
||||
case BIDIRECTIONAL -> controlProperty.bindBidirectional(modelProperty);
|
||||
case READ -> controlProperty.bind(modelProperty);
|
||||
case WRITE -> modelProperty.bind(controlProperty);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the binding direction for a node based on its "bind:direction" property.
|
||||
*
|
||||
* @param node the node whose direction property is read
|
||||
* @return the BindDirection specified or a default
|
||||
*/
|
||||
private BindDirection getDirection(final Node node) {
|
||||
return BindDirection.fromString((String) node.getProperties().get("bind:direction"));
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package de.neitzel.fx.mvvm;
|
||||
|
||||
import javafx.fxml.Initializable;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.ResourceBundle;
|
||||
|
||||
/**
|
||||
* A generic JavaFX controller that holds a GenericViewModel for the given model type.
|
||||
* Intended for use with a custom FXMLLoader that performs automatic property bindings.
|
||||
*
|
||||
* @param <T> the type of the model
|
||||
*/
|
||||
public class GenericViewController<T> implements Initializable {
|
||||
|
||||
/**
|
||||
* The GenericViewModel instance wrapping the underlying model.
|
||||
*/
|
||||
private GenericViewModel<T> viewModel;
|
||||
|
||||
/**
|
||||
* Creates a new controller instance in an uninitialized state.
|
||||
*
|
||||
* <p>The constructor does not set a model. Call {@link #setModel(Object)} to
|
||||
* attach a model instance before performing view bindings or accessing the view model via
|
||||
* {@link #getViewModel()}.</p>
|
||||
*/
|
||||
public GenericViewController() {
|
||||
// default constructor only ...
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the original model instance from the ViewModel.
|
||||
*
|
||||
* @return the model instance
|
||||
*/
|
||||
public T getModel() {
|
||||
return viewModel.getModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the model for this controller by wrapping it in a GenericViewModel.
|
||||
*
|
||||
* @param model the model to be used by the ViewModel
|
||||
*/
|
||||
public void setModel(T model) {
|
||||
this.viewModel = new GenericViewModel<>(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard JavaFX initialize method. No-op, as actual binding is done externally via FXMLLoader extension.
|
||||
*
|
||||
* @param location the location used to resolve relative paths for the root object, or null if unknown
|
||||
* @param resources the resources used to localize the root object, or null if not localized
|
||||
*/
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
// Real binding happens in FXMLLoader extension
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the GenericViewModel used by this controller.
|
||||
*
|
||||
* @return the GenericViewModel instance
|
||||
*/
|
||||
public GenericViewModel<T> getViewModel() {
|
||||
return viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package de.neitzel.fx.mvvm;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.beans.property.SimpleLongProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
|
||||
import java.beans.BeanInfo;
|
||||
import java.beans.IntrospectionException;
|
||||
import java.beans.Introspector;
|
||||
import java.beans.PropertyDescriptor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A generic ViewModel for JavaFX using reflection to automatically create properties for model attributes.
|
||||
* Supports binding between JavaFX controls and the model via JavaFX properties.
|
||||
*
|
||||
* @param <T> the type of the underlying model
|
||||
*/
|
||||
public class GenericViewModel<T> {
|
||||
/**
|
||||
* The original model instance.
|
||||
*/
|
||||
private final T model;
|
||||
|
||||
/**
|
||||
* A map holding JavaFX properties corresponding to the model's fields.
|
||||
*/
|
||||
private final Map<String, Property<?>> properties = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Constructs a GenericViewModel for the given model instance.
|
||||
*
|
||||
* @param model the model to wrap
|
||||
*/
|
||||
public GenericViewModel(T model) {
|
||||
this.model = model;
|
||||
initProperties();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes JavaFX properties for all readable and writable model fields using reflection.
|
||||
*/
|
||||
private void initProperties() {
|
||||
Class<?> clazz = model.getClass();
|
||||
for (PropertyDescriptor pd : getPropertyDescriptors(clazz)) {
|
||||
Method getter = pd.getReadMethod();
|
||||
Method setter = pd.getWriteMethod();
|
||||
String name = pd.getName();
|
||||
|
||||
if (getter != null && setter != null) {
|
||||
Class<?> propertyType = pd.getPropertyType();
|
||||
try {
|
||||
Object value = getter.invoke(model);
|
||||
Property<?> property = createJavaFXProperty(propertyType, value);
|
||||
properties.put(name, property);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to initialize property: " + name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all readable/writable property descriptors of the given class.
|
||||
*
|
||||
* @param clazz the class to introspect
|
||||
* @return a list of property descriptors
|
||||
*/
|
||||
private List<PropertyDescriptor> getPropertyDescriptors(Class<?> clazz) {
|
||||
try {
|
||||
BeanInfo info = Introspector.getBeanInfo(clazz, Object.class);
|
||||
return Arrays.asList(info.getPropertyDescriptors());
|
||||
} catch (IntrospectionException e) {
|
||||
throw new RuntimeException("Cannot introspect model class: " + clazz, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an appropriate JavaFX Property instance for the given type and initial value.
|
||||
*
|
||||
* @param type the type of the property (e.g., String.class, int.class)
|
||||
* @param initialValue the initial value of the property
|
||||
* @return a new JavaFX Property instance
|
||||
*/
|
||||
private Property<?> createJavaFXProperty(Class<?> type, Object initialValue) {
|
||||
if (type == String.class) return new SimpleStringProperty((String) initialValue);
|
||||
if (type == int.class || type == Integer.class) return new SimpleIntegerProperty((Integer) initialValue);
|
||||
if (type == double.class || type == Double.class) return new SimpleDoubleProperty((Double) initialValue);
|
||||
if (type == boolean.class || type == Boolean.class) return new SimpleBooleanProperty((Boolean) initialValue);
|
||||
if (type == long.class || type == Long.class) return new SimpleLongProperty((Long) initialValue);
|
||||
// Fallback for unsupported types:
|
||||
return new SimpleObjectProperty<>(initialValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the JavaFX property associated with the given name and type.
|
||||
*
|
||||
* @param propertyClass the expected property type (e.g., StringProperty.class)
|
||||
* @param name the name of the model field
|
||||
* @param <P> the type of the Property
|
||||
* @return the corresponding JavaFX property
|
||||
* @throws IllegalArgumentException if the property doesn't exist or the type mismatches
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <P extends Property<?>> P property(Class<P> propertyClass, String name) {
|
||||
Property<?> prop = properties.get(name);
|
||||
if (prop == null) {
|
||||
throw new IllegalArgumentException("No property found for name: " + name);
|
||||
}
|
||||
if (!propertyClass.isInstance(prop)) {
|
||||
throw new IllegalArgumentException("Property type mismatch: expected " + propertyClass.getSimpleName());
|
||||
}
|
||||
return (P) prop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the current values of all properties back into the model using their respective setters.
|
||||
*/
|
||||
public void save() {
|
||||
for (PropertyDescriptor pd : getPropertyDescriptors(model.getClass())) {
|
||||
String name = pd.getName();
|
||||
Method setter = pd.getWriteMethod();
|
||||
if (setter != null && properties.containsKey(name)) {
|
||||
try {
|
||||
Object value = properties.get(name).getValue();
|
||||
setter.invoke(model, value);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to save property: " + name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the wrapped model instance.
|
||||
*
|
||||
* @return the model
|
||||
*/
|
||||
public T getModel() {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.beans.DefaultProperty;
|
||||
import javafx.scene.Parent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The list of binding elements declared inside {@code fx:define}.
|
||||
* <p>
|
||||
* The list is not a scene-graph child. FXML adds each {@link DirectBinding},
|
||||
* {@link ComputedBinding}, or {@link Repeat} child to {@link #getElements()}.
|
||||
*/
|
||||
@DefaultProperty("elements")
|
||||
public class BindingList {
|
||||
|
||||
private final List<Object> elements = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Creates a list and registers it with the load in progress.
|
||||
*/
|
||||
public BindingList() {
|
||||
LoadSession.register(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the binding elements in this list.
|
||||
* <p>
|
||||
* FXML appends child elements to the returned list. A child is a {@link DirectBinding}, a
|
||||
* {@link ComputedBinding}, or a {@link Repeat}.
|
||||
*
|
||||
* @return the mutable list of binding elements
|
||||
*/
|
||||
public List<Object> getElements() {
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies every binding element in this list.
|
||||
*
|
||||
* @param namespace objects addressed by {@code fx:id}
|
||||
* @param viewModel the ViewModel for this View, or a replacement that exposes JavaFX properties
|
||||
* by name
|
||||
* @param controller the Controller for this View, or {@code null} when the document names none
|
||||
* @param root the scene-graph root of this View
|
||||
* @param context the container that creates a named Converter
|
||||
*/
|
||||
void apply(Map<String, Object> namespace, Object viewModel, Object controller, Parent root,
|
||||
ApplicationContext context) {
|
||||
for (Object element : elements) {
|
||||
if (element instanceof DirectBinding directBinding) {
|
||||
DirectBindingApplier.apply(namespace, viewModel, directBinding, context);
|
||||
} else if (element instanceof ComputedBinding computedBinding) {
|
||||
ComputedBindingApplier.apply(namespace, viewModel, controller, root, computedBinding);
|
||||
} else if (element instanceof Repeat repeat) {
|
||||
RepeatApplier.apply(namespace, viewModel, repeat, context);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Not a binding element: " + element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Carries one child ViewModel's current notices onto its parent.
|
||||
* <p>
|
||||
* Each copy is keyed by the identity of the child {@link Message}. A later text change on that
|
||||
* child notice updates the same copy, and dropping the child notice drops the copy. The copy's
|
||||
* source is the Attribute name, a dot, and the child source. A further parent carries that copy
|
||||
* the same way, so the source grows into a Path. The copy is not logged again.
|
||||
*/
|
||||
final class ChildMessageMirror implements MessageListener {
|
||||
|
||||
private final ViewModel parent;
|
||||
private final String attribute;
|
||||
private final ViewModel child;
|
||||
private final Map<Message, Message> copies = new IdentityHashMap<>();
|
||||
|
||||
private ChildMessageMirror(ViewModel parent, String attribute, ViewModel child) {
|
||||
this.parent = parent;
|
||||
this.attribute = attribute;
|
||||
this.child = child;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts carrying {@code child} onto {@code parent} under {@code attribute}.
|
||||
* <p>
|
||||
* Notices already on the child are copied, and notices added later are copied as they arrive.
|
||||
*
|
||||
* @param parent the parent ViewModel
|
||||
* @param attribute the Attribute that leads to the child
|
||||
* @param child the child ViewModel
|
||||
* @return the mirror, so it can be detached when the Attribute is cleared
|
||||
*/
|
||||
static ChildMessageMirror attach(ViewModel parent, String attribute, ViewModel child) {
|
||||
ChildMessageMirror mirror = new ChildMessageMirror(parent, attribute, child);
|
||||
child.addMessageListener(mirror);
|
||||
for (Message message : List.copyOf(child.getMessages())) {
|
||||
mirror.carry(message);
|
||||
}
|
||||
return mirror;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops carrying and drops this Attribute's copies from the parent.
|
||||
*/
|
||||
void detach() {
|
||||
child.removeMessageListener(this);
|
||||
List<Message> dropped = new ArrayList<>(copies.values());
|
||||
copies.clear();
|
||||
for (Message copy : dropped) {
|
||||
parent.removeCarriedMessage(copy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies {@code message} onto the parent unless this parent already holds it.
|
||||
*
|
||||
* @param message the child notice
|
||||
*/
|
||||
@Override
|
||||
public void added(Message message) {
|
||||
carry(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the parent's copy of {@code message}.
|
||||
*
|
||||
* @param message the child notice
|
||||
*/
|
||||
@Override
|
||||
public void removed(Message message) {
|
||||
Message copy = copies.remove(message);
|
||||
if (copy != null) {
|
||||
parent.removeCarriedMessage(copy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the child's new text onto the same parent copy.
|
||||
*
|
||||
* @param message the child notice
|
||||
*/
|
||||
@Override
|
||||
public void textChanged(Message message) {
|
||||
Message copy = copies.get(message);
|
||||
if (copy == null) {
|
||||
return;
|
||||
}
|
||||
copy.setText(message.getText());
|
||||
parent.noteMessageText(copy);
|
||||
}
|
||||
|
||||
private void carry(Message message) {
|
||||
if (copies.containsKey(message) || repeats(message)) {
|
||||
return;
|
||||
}
|
||||
Message copy = message.withExtendedSource(attribute + "." + message.getSource());
|
||||
copies.put(message, copy);
|
||||
parent.addCarriedMessage(copy);
|
||||
}
|
||||
|
||||
/**
|
||||
* A cycle would carry a notice back onto a ViewModel that already holds it, or that holds
|
||||
* the notice it was copied from.
|
||||
*
|
||||
* @param message the child notice
|
||||
* @return {@code true} when the parent already has this notice or its origin
|
||||
*/
|
||||
private boolean repeats(Message message) {
|
||||
Message current = message;
|
||||
Set<Message> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
while (current != null && seen.add(current)) {
|
||||
if (parent.getMessages().contains(current)) {
|
||||
return true;
|
||||
}
|
||||
current = current.origin();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.util.Builder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.URL;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Loads a Component from {@code ClassName.fxml} in the Component's package.
|
||||
* <p>
|
||||
* The container creates the instance. The document uses {@code fx:root} with that instance as
|
||||
* the root and as the Controller. Bindings declared in the document wait until the model
|
||||
* property holds a non-null Model. {@link ViewModelClass} on the Component names the ViewModel
|
||||
* class constructed for that Model. A Component that implements {@link HasViewModel} is handed
|
||||
* that ViewModel after the property is set and before those bindings run.
|
||||
*/
|
||||
final class Components {
|
||||
|
||||
private Components() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a builder for a type the container knows.
|
||||
* <p>
|
||||
* An unknown type, such as a JavaFX control, makes {@link ApplicationContext#getComponent}
|
||||
* throw {@link IllegalArgumentException}. This method returns null so FXML constructs that
|
||||
* type itself. A Component that extends a JavaFX class is also left to FXML when the document
|
||||
* names that JavaFX class: the container would otherwise hand back the Component. A failure
|
||||
* while creating a known Component is not caught. The first Component of a class in one load
|
||||
* is the cached singleton. A second Component of that class is a new injected instance, so the
|
||||
* document can show two of them.
|
||||
*
|
||||
* @param type the type FXML is about to construct
|
||||
* @param context the container that creates Components
|
||||
* @return a builder for a Component, or {@code null} when the type is not a Component
|
||||
*/
|
||||
static Builder<?> builder(Class<?> type, ApplicationContext context) {
|
||||
Object component;
|
||||
try {
|
||||
component = context.getComponent(type.asSubclass(Object.class));
|
||||
} catch (IllegalArgumentException notAComponent) {
|
||||
return null;
|
||||
}
|
||||
if (component.getClass() != type) {
|
||||
return null;
|
||||
}
|
||||
if (!LoadSession.claimFirst(type)) {
|
||||
component = context.createComponent(type.asSubclass(Object.class));
|
||||
}
|
||||
Object created = component;
|
||||
return () -> load(created, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Component and loads its document.
|
||||
* <p>
|
||||
* The instance is not the cached singleton. Its dependencies still come from
|
||||
* {@link ApplicationContext#getComponent}. Bindings wait until {@link #setModel} runs.
|
||||
*
|
||||
* @param type the Component class
|
||||
* @param context the container that creates the instance and its dependencies
|
||||
* @return the loaded Component
|
||||
*/
|
||||
static Object fresh(Class<?> type, ApplicationContext context) {
|
||||
Object component = context.createComponent(type.asSubclass(Object.class));
|
||||
return load(component, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Component's model property.
|
||||
* <p>
|
||||
* A non-null value selects the ViewModel and then runs the Component's bindings.
|
||||
*
|
||||
* @param component the Component
|
||||
* @param model the Model instance
|
||||
*/
|
||||
static void setModel(Object component, Object model) {
|
||||
modelProperty(component).setValue(model);
|
||||
}
|
||||
|
||||
private static Object load(Object component, ApplicationContext context) {
|
||||
Class<?> type = component.getClass();
|
||||
String document = type.getSimpleName() + ".fxml";
|
||||
URL fxml = type.getResource(document);
|
||||
if (fxml == null) {
|
||||
throw new IllegalArgumentException("No component document " + document + " for " + type.getName());
|
||||
}
|
||||
LoadSession nested = LoadSession.nest();
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(fxml);
|
||||
loader.setRoot(component);
|
||||
loader.setController(component);
|
||||
loader.setBuilderFactory(nestedType -> builder(nestedType, context));
|
||||
loader.load();
|
||||
watchModel(component, List.copyOf(nested.bindingLists()), loader.getNamespace(), context);
|
||||
return component;
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Could not load component " + fxml, failure);
|
||||
} finally {
|
||||
LoadSession.unnest();
|
||||
}
|
||||
}
|
||||
|
||||
private static void watchModel(Object component, List<BindingList> lists, Map<String, Object> namespace,
|
||||
ApplicationContext context) {
|
||||
Property<Object> model = modelProperty(component);
|
||||
boolean[] applied = {false};
|
||||
model.addListener((observable, oldValue, newValue) -> {
|
||||
if (newValue == null || applied[0]) {
|
||||
return;
|
||||
}
|
||||
applied[0] = true;
|
||||
Object viewModel = ViewModels.of(newValue, ViewModels.namedOn(component.getClass()));
|
||||
if (component instanceof HasViewModel hasViewModel && viewModel instanceof ViewModel bound) {
|
||||
hasViewModel.setViewModel(bound);
|
||||
}
|
||||
Parent root = (Parent) component;
|
||||
for (BindingList list : lists) {
|
||||
list.apply(namespace, viewModel, component, root, context);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Property<Object> modelProperty(Object component) {
|
||||
try {
|
||||
Method method = component.getClass().getMethod("modelProperty");
|
||||
Object value = method.invoke(component);
|
||||
if (!(value instanceof Property<?> property)) {
|
||||
throw new IllegalArgumentException("modelProperty did not return a property");
|
||||
}
|
||||
return (Property<Object>) property;
|
||||
} catch (NoSuchMethodException failure) {
|
||||
throw new IllegalArgumentException("No model property on " + component.getClass().getName(), failure);
|
||||
} catch (IllegalAccessException | InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Could not read model property", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* A Computed binding declared in FXML.
|
||||
* <p>
|
||||
* The element names the control by {@code fx:id}, the control property, and a no-argument
|
||||
* method on the Controller. The method value uses a {@code #method} prefix, such as
|
||||
* {@code #workingAge}. The method may read the Controller's fields. Its result is written to
|
||||
* the control property. The method does not write the Model.
|
||||
*/
|
||||
public class ComputedBinding {
|
||||
|
||||
private String control;
|
||||
private String property;
|
||||
private String method;
|
||||
|
||||
/**
|
||||
* Sets the {@code fx:id} of the control.
|
||||
*
|
||||
* @param control the control id
|
||||
*/
|
||||
public void setControl(String control) {
|
||||
this.control = control;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the control property name, such as {@code text}.
|
||||
*
|
||||
* @param property the property name
|
||||
*/
|
||||
public void setProperty(String property) {
|
||||
this.property = property;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Controller method. A {@code #method} value names a no-argument method.
|
||||
*
|
||||
* @param method the method value, such as {@code #workingAge}
|
||||
*/
|
||||
public void setMethod(String method) {
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@code fx:id} of the control.
|
||||
*
|
||||
* @return the control id
|
||||
*/
|
||||
public String getControl() {
|
||||
return control;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the control property name.
|
||||
*
|
||||
* @return the property name
|
||||
*/
|
||||
public String getProperty() {
|
||||
return property;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Controller method value.
|
||||
*
|
||||
* @return the method value, such as {@code #workingAge}
|
||||
*/
|
||||
public String getMethod() {
|
||||
return method;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.control.Control;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Writes a Controller method result into one control property and keeps it current.
|
||||
*/
|
||||
final class ComputedBindingApplier {
|
||||
|
||||
private ComputedBindingApplier() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one Computed binding.
|
||||
* <p>
|
||||
* Calls the Controller method and writes the result to the control property. The method
|
||||
* runs again when a JavaFX property changes on a control in this View or on this View's
|
||||
* ViewModel, other than the target property. Writing the target does not call the method
|
||||
* again, so the write does not loop. The Model is not written.
|
||||
*
|
||||
* @param namespace objects addressed by {@code fx:id}
|
||||
* @param viewModel the ViewModel for this View
|
||||
* @param controller the Controller for this View
|
||||
* @param root the scene-graph root of this View
|
||||
* @param binding the declaration
|
||||
*/
|
||||
static void apply(
|
||||
Map<String, Object> namespace,
|
||||
Object viewModel,
|
||||
Object controller,
|
||||
Parent root,
|
||||
ComputedBinding binding) {
|
||||
Object control = namespace.get(binding.getControl());
|
||||
if (control == null) {
|
||||
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
|
||||
}
|
||||
Property<Object> target = controlProperty(control, binding.getProperty());
|
||||
Runnable refresh = refresh(controller, binding.getMethod(), target);
|
||||
refresh.run();
|
||||
watchControls(root, target, refresh);
|
||||
if (viewModel instanceof ViewModel model) {
|
||||
model.addAttributeListener((observable, oldValue, newValue) -> refresh.run());
|
||||
}
|
||||
}
|
||||
|
||||
private static Runnable refresh(Object controller, String methodValue, Property<Object> target) {
|
||||
boolean[] running = {false};
|
||||
return () -> {
|
||||
if (running[0]) {
|
||||
return;
|
||||
}
|
||||
running[0] = true;
|
||||
try {
|
||||
target.setValue(invoke(controller, methodValue));
|
||||
} finally {
|
||||
running[0] = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void watchControls(Parent root, Property<?> target, Runnable refresh) {
|
||||
watchNode(root, target, refresh);
|
||||
}
|
||||
|
||||
private static void watchNode(Node node, Property<?> target, Runnable refresh) {
|
||||
if (node instanceof Control control) {
|
||||
watchProperties(control, target, refresh);
|
||||
}
|
||||
if (node instanceof Parent parent) {
|
||||
for (Node child : parent.getChildrenUnmodifiable()) {
|
||||
watchNode(child, target, refresh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void watchProperties(Object bean, Property<?> target, Runnable refresh) {
|
||||
ChangeListener<Object> listener = (observable, oldValue, newValue) -> refresh.run();
|
||||
for (Method method : bean.getClass().getMethods()) {
|
||||
if (method.getParameterCount() != 0 || !method.getName().endsWith("Property")) {
|
||||
continue;
|
||||
}
|
||||
if (!ObservableValue.class.isAssignableFrom(method.getReturnType())) {
|
||||
continue;
|
||||
}
|
||||
Object value = readProperty(bean, method);
|
||||
if (value instanceof ObservableValue<?> observable && observable != target) {
|
||||
((ObservableValue<Object>) observable).addListener(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Object readProperty(Object bean, Method method) {
|
||||
try {
|
||||
return method.invoke(bean);
|
||||
} catch (IllegalAccessException | InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Could not read property " + method.getName(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Property<Object> controlProperty(Object control, String propertyName) {
|
||||
String methodName = propertyName + "Property";
|
||||
try {
|
||||
Method method = control.getClass().getMethod(methodName);
|
||||
Object value = method.invoke(control);
|
||||
if (!(value instanceof Property<?> property)) {
|
||||
throw new IllegalArgumentException(methodName + " did not return a property");
|
||||
}
|
||||
return (Property<Object>) property;
|
||||
} catch (NoSuchMethodException failure) {
|
||||
throw new IllegalArgumentException(
|
||||
"No property " + propertyName + " on " + control.getClass().getName(), failure);
|
||||
} catch (IllegalAccessException | InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Could not read property " + propertyName, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Object invoke(Object controller, String methodValue) {
|
||||
if (controller == null) {
|
||||
throw new IllegalArgumentException("No controller for " + methodValue);
|
||||
}
|
||||
String name = methodName(methodValue);
|
||||
try {
|
||||
Method method = controller.getClass().getMethod(name);
|
||||
return method.invoke(controller);
|
||||
} catch (NoSuchMethodException failure) {
|
||||
throw new IllegalArgumentException("No computed method \"" + methodValue + "\"", failure);
|
||||
} catch (IllegalAccessException | InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Could not call " + methodValue, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String methodName(String methodValue) {
|
||||
if (methodValue != null && methodValue.startsWith("#")) {
|
||||
return methodValue.substring(1);
|
||||
}
|
||||
return methodValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* The developer-written translation between a Direct binding's Attribute value and its control value.
|
||||
* <p>
|
||||
* The container constructs the class and injects its services. {@link #toControlValue(Object)} turns
|
||||
* the Attribute value into the control value. {@link #toAttributeValue(Object)} turns the control
|
||||
* value into the Attribute value and runs only when the Direct binding runs toward the Model.
|
||||
* A toward-view binding does not call the reverse method, so a converter used only toward the View
|
||||
* can leave it unimplemented. When a method cannot produce a value, it throws a runtime exception.
|
||||
* The binding then keeps the previous value.
|
||||
*/
|
||||
public interface Converter {
|
||||
|
||||
/**
|
||||
* Turns the Attribute value into the control value.
|
||||
*
|
||||
* @param attributeValue the current Attribute value, which may be null
|
||||
* @return the value written to the control
|
||||
*/
|
||||
Object toControlValue(Object attributeValue);
|
||||
|
||||
/**
|
||||
* Turns the control value into the Attribute value.
|
||||
* <p>
|
||||
* Called only when the Direct binding runs toward the Model ({@code model} or {@code both}).
|
||||
* A toward-view binding does not require this method.
|
||||
*
|
||||
* @param controlValue the current control value
|
||||
* @return the value written to the Attribute
|
||||
*/
|
||||
default Object toAttributeValue(Object controlValue) {
|
||||
throw new UnsupportedOperationException("no reverse conversion for " + controlValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* A Direct binding declared in FXML.
|
||||
* <p>
|
||||
* The element names the control by {@code fx:id}, the control property, the Path, and the
|
||||
* direction, and an optional Converter class. A Path of one name is that Attribute of this
|
||||
* View's ViewModel. Further names step into the ViewModel of a Complex attribute, and the
|
||||
* direction applies to the last name. When that last name is complex, the control receives the
|
||||
* Model instance behind the ViewModel. Direction values are {@code view}, {@code model}, and
|
||||
* {@code both}. The Converter value is the class name. The container creates that class.
|
||||
*/
|
||||
public class DirectBinding {
|
||||
|
||||
private String control;
|
||||
private String property;
|
||||
private String path;
|
||||
private String direction;
|
||||
private String converter;
|
||||
|
||||
/**
|
||||
* Sets the {@code fx:id} of the control.
|
||||
*
|
||||
* @param control the control id
|
||||
*/
|
||||
public void setControl(String control) {
|
||||
this.control = control;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the control property name, such as {@code text}.
|
||||
*
|
||||
* @param property the property name
|
||||
*/
|
||||
public void setProperty(String property) {
|
||||
this.property = property;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Path. One name is an Attribute of this View's ViewModel.
|
||||
*
|
||||
* @param path the path
|
||||
*/
|
||||
public void setPath(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the direction. Accepted values are {@code view}, {@code model}, and {@code both}.
|
||||
*
|
||||
* @param direction the direction value
|
||||
*/
|
||||
public void setDirection(String direction) {
|
||||
this.direction = direction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Converter class name. The container creates that class.
|
||||
*
|
||||
* @param converter the converter class name, or {@code null} when values pass through
|
||||
*/
|
||||
public void setConverter(String converter) {
|
||||
this.converter = converter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@code fx:id} of the control.
|
||||
*
|
||||
* @return the control id
|
||||
*/
|
||||
public String getControl() {
|
||||
return control;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the control property name.
|
||||
*
|
||||
* @return the property name
|
||||
*/
|
||||
public String getProperty() {
|
||||
return property;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Path.
|
||||
*
|
||||
* @return the path
|
||||
*/
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the direction value.
|
||||
*
|
||||
* @return {@code view}, {@code model}, or {@code both}
|
||||
*/
|
||||
public String getDirection() {
|
||||
return direction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Converter class name.
|
||||
*
|
||||
* @return the class name, or {@code null} when values pass through
|
||||
*/
|
||||
public String getConverter() {
|
||||
return converter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.collections.ObservableList;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Connects one Direct binding to a control property and a ViewModel Attribute.
|
||||
*/
|
||||
final class DirectBindingApplier {
|
||||
|
||||
private DirectBindingApplier() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one Direct binding.
|
||||
*
|
||||
* @param namespace objects addressed by {@code fx:id}
|
||||
* @param viewModel the ViewModel for this View
|
||||
* @param binding the declaration
|
||||
* @param context the container that creates a named Converter
|
||||
*/
|
||||
static void apply(Map<String, Object> namespace, Object viewModel, DirectBinding binding,
|
||||
ApplicationContext context) {
|
||||
Object control = namespace.get(binding.getControl());
|
||||
if (control == null) {
|
||||
throw new IllegalArgumentException("No control \"" + binding.getControl() + "\"");
|
||||
}
|
||||
selectComponentViewModel(control, viewModel, binding);
|
||||
Resolved resolved = resolve(viewModel, binding.getPath());
|
||||
if (resolved == null) {
|
||||
return;
|
||||
}
|
||||
Property<?> controlProperty = controlProperty(control, binding.getProperty());
|
||||
Property<?> attribute = resolved.attribute;
|
||||
if (resolved.complex) {
|
||||
switch (binding.getDirection()) {
|
||||
case "both" -> alignBothComplex(controlProperty, attribute);
|
||||
case "view" -> alignTowardViewComplex(controlProperty, attribute);
|
||||
case "model" -> alignTowardModelComplex(controlProperty, attribute);
|
||||
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (attribute.getValue() instanceof ObservableList<?> values) {
|
||||
assignList(controlProperty, values);
|
||||
return;
|
||||
}
|
||||
String converterName = binding.getConverter();
|
||||
if (converterName == null || converterName.isEmpty()) {
|
||||
alignPlain(controlProperty, attribute, binding.getDirection());
|
||||
return;
|
||||
}
|
||||
Converter converter = converter(context, converterName);
|
||||
switch (binding.getDirection()) {
|
||||
case "both" -> alignConverted(
|
||||
controlProperty, attribute, converter, resolved.owner, resolved.name, true, true);
|
||||
case "view" -> alignConverted(
|
||||
controlProperty, attribute, converter, resolved.owner, resolved.name, true, false);
|
||||
case "model" -> alignConverted(
|
||||
controlProperty, attribute, converter, resolved.owner, resolved.name, false, true);
|
||||
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
|
||||
}
|
||||
}
|
||||
|
||||
private static void alignPlain(Property<?> controlProperty, Property<?> attribute, String direction) {
|
||||
switch (direction) {
|
||||
case "both" -> alignBoth(controlProperty, attribute);
|
||||
case "view" -> alignTowardView(controlProperty, attribute);
|
||||
case "model" -> alignTowardModel(controlProperty, attribute);
|
||||
default -> throw new IllegalArgumentException("Not a direction: " + direction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Attribute a Path names, the ViewModel that owns it, and whether that Attribute is complex.
|
||||
* <p>
|
||||
* {@code name} is the last segment. A conversion failure is recorded on {@code owner} with that
|
||||
* name as the source, so a parent only carries the extended copy.
|
||||
*/
|
||||
private static final class Resolved {
|
||||
private final Property<?> attribute;
|
||||
private final boolean complex;
|
||||
private final ViewModel owner;
|
||||
private final String name;
|
||||
|
||||
private Resolved(Property<?> attribute, boolean complex, ViewModel owner, String name) {
|
||||
this.attribute = attribute;
|
||||
this.complex = complex;
|
||||
this.owner = owner;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows a Path to the Attribute it names.
|
||||
* <p>
|
||||
* One name is an Attribute of {@code viewModel}. Each earlier name steps into the ViewModel
|
||||
* held by that Complex attribute. A null Complex attribute along the Path leaves the binding
|
||||
* unapplied.
|
||||
*
|
||||
* @param viewModel the ViewModel for this View
|
||||
* @param path the Path
|
||||
* @return the Attribute for the last name, or {@code null} when a Complex attribute
|
||||
* along the Path is null
|
||||
*/
|
||||
private static Resolved resolve(Object viewModel, String path) {
|
||||
String[] names = path.split("\\.", -1);
|
||||
Object current = viewModel;
|
||||
for (int index = 0; index < names.length - 1; index++) {
|
||||
if (!(current instanceof ViewModel stepModel)) {
|
||||
throw missing(path);
|
||||
}
|
||||
String name = names[index];
|
||||
Property<?> step = stepModel.property(name);
|
||||
if (step == null || !stepModel.isComplex(name)) {
|
||||
throw missing(path);
|
||||
}
|
||||
Object nested = step.getValue();
|
||||
if (nested == null) {
|
||||
return null;
|
||||
}
|
||||
if (!(nested instanceof ViewModel child)) {
|
||||
throw missing(path);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
String last = names[names.length - 1];
|
||||
Property<?> attribute = propertyOf(current, last);
|
||||
if (attribute == null) {
|
||||
throw missing(path);
|
||||
}
|
||||
boolean complex = current instanceof ViewModel stepped && stepped.isComplex(last);
|
||||
ViewModel owner = current instanceof ViewModel model ? model : null;
|
||||
return new Resolved(attribute, complex, owner, last);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ViewModel named on a Component before the Path is read.
|
||||
* <p>
|
||||
* The binding sets the Component's model property. Reading the Path would otherwise build a
|
||||
* generic ViewModel first and fix that class.
|
||||
*
|
||||
* @param control the control named by the binding
|
||||
* @param viewModel the ViewModel for this View
|
||||
* @param binding the declaration
|
||||
*/
|
||||
private static void selectComponentViewModel(Object control, Object viewModel, DirectBinding binding) {
|
||||
if (!"model".equals(binding.getProperty())) {
|
||||
return;
|
||||
}
|
||||
Class<?> requested = ViewModels.namedOn(control.getClass());
|
||||
if (requested == null) {
|
||||
return;
|
||||
}
|
||||
Object instance = instanceAt(viewModel, binding.getPath());
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
ViewModels.of(instance, requested);
|
||||
}
|
||||
|
||||
private static Object instanceAt(Object viewModel, String path) {
|
||||
if (!(viewModel instanceof ViewModel root)) {
|
||||
return null;
|
||||
}
|
||||
Object current = root.model();
|
||||
for (String name : path.split("\\.", -1)) {
|
||||
if (current == null) {
|
||||
return null;
|
||||
}
|
||||
current = readAttribute(current, name);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static Object readAttribute(Object bean, String name) {
|
||||
String suffix = Character.toUpperCase(name.charAt(0)) + name.substring(1);
|
||||
for (String prefix : List.of("get", "is")) {
|
||||
try {
|
||||
Method method = bean.getClass().getMethod(prefix + suffix);
|
||||
return method.invoke(bean);
|
||||
} catch (NoSuchMethodException notThisPrefix) {
|
||||
continue;
|
||||
} catch (IllegalAccessException | InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Could not read attribute " + name, failure);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("No read-write attribute for path \"" + name + "\"");
|
||||
}
|
||||
|
||||
private static Property<?> propertyOf(Object target, String name) {
|
||||
if (target instanceof ViewModel viewModel) {
|
||||
return viewModel.property(name);
|
||||
}
|
||||
return NamedProperties.get(target, name);
|
||||
}
|
||||
|
||||
private static IllegalArgumentException missing(String path) {
|
||||
return new IllegalArgumentException("No read-write attribute for path \"" + path + "\"");
|
||||
}
|
||||
|
||||
private static Converter converter(ApplicationContext context, String className) {
|
||||
try {
|
||||
Class<?> type = Class.forName(className);
|
||||
Object instance = context.getComponent(type.asSubclass(Object.class));
|
||||
if (!(instance instanceof Converter created)) {
|
||||
throw new IllegalArgumentException("Not a converter: " + className);
|
||||
}
|
||||
return created;
|
||||
} catch (ClassNotFoundException failure) {
|
||||
throw new IllegalArgumentException("No converter " + className, failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aligns a converted Attribute. Failures are recorded on {@code owner} with {@code sourceName},
|
||||
* which is the last name of the Path.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignConverted(Property<?> controlProperty, Property<?> attribute, Converter converter,
|
||||
ViewModel owner, String sourceName, boolean towardView, boolean towardModel) {
|
||||
Property<Object> control = (Property<Object>) controlProperty;
|
||||
Property<Object> source = (Property<Object>) attribute;
|
||||
Link link = new Link();
|
||||
pushControl(control, source, converter, owner, sourceName, link);
|
||||
if (towardView) {
|
||||
source.addListener((observable, oldValue, newValue) -> {
|
||||
if (!link.applying) {
|
||||
pushControl(control, source, converter, owner, sourceName, link);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (towardModel) {
|
||||
control.addListener((observable, oldValue, newValue) -> {
|
||||
if (!link.applying) {
|
||||
pushAttribute(control, source, converter, owner, sourceName, link);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void pushControl(Property<Object> control, Property<Object> attribute, Converter converter,
|
||||
ViewModel owner, String source, Link link) {
|
||||
try {
|
||||
Object shown = converter.toControlValue(attribute.getValue());
|
||||
link.applying = true;
|
||||
try {
|
||||
control.setValue(shown);
|
||||
} finally {
|
||||
link.applying = false;
|
||||
}
|
||||
owner.clearBindingFailure(source);
|
||||
} catch (RuntimeException failure) {
|
||||
owner.reportBindingFailure(source, failureText(failure));
|
||||
}
|
||||
}
|
||||
|
||||
private static void pushAttribute(Property<Object> control, Property<Object> attribute, Converter converter,
|
||||
ViewModel owner, String source, Link link) {
|
||||
try {
|
||||
Object converted = converter.toAttributeValue(control.getValue());
|
||||
link.applying = true;
|
||||
try {
|
||||
attribute.setValue(converted);
|
||||
} finally {
|
||||
link.applying = false;
|
||||
}
|
||||
owner.clearBindingFailure(source);
|
||||
} catch (RuntimeException failure) {
|
||||
owner.reportBindingFailure(source, failureText(failure));
|
||||
}
|
||||
}
|
||||
|
||||
private static String failureText(RuntimeException failure) {
|
||||
String text = failure.getMessage();
|
||||
if (text == null || text.isEmpty()) {
|
||||
return "Conversion failed";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards one converted binding against hearing its own writes.
|
||||
*/
|
||||
private static final class Link {
|
||||
private boolean applying;
|
||||
}
|
||||
|
||||
private static Property<?> controlProperty(Object control, String propertyName) {
|
||||
String methodName = propertyName + "Property";
|
||||
try {
|
||||
Method method = control.getClass().getMethod(methodName);
|
||||
Object value = method.invoke(control);
|
||||
if (!(value instanceof Property<?> property)) {
|
||||
throw new IllegalArgumentException(methodName + " did not return a property");
|
||||
}
|
||||
return property;
|
||||
} catch (NoSuchMethodException failure) {
|
||||
throw new IllegalArgumentException(
|
||||
"No property " + propertyName + " on " + control.getClass().getName(), failure);
|
||||
} catch (IllegalAccessException | InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Could not read property " + propertyName, failure);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignBothComplex(Property<?> controlProperty, Property<?> attribute) {
|
||||
Property<Object> control = (Property<Object>) controlProperty;
|
||||
Property<Object> source = (Property<Object>) attribute;
|
||||
control.setValue(modelBehind(source.getValue()));
|
||||
source.addListener((observable, oldValue, newValue) -> {
|
||||
Object model = modelBehind(newValue);
|
||||
if (control.getValue() != model) {
|
||||
control.setValue(model);
|
||||
}
|
||||
});
|
||||
control.addListener((observable, oldValue, newValue) -> {
|
||||
if (modelBehind(source.getValue()) == newValue) {
|
||||
return;
|
||||
}
|
||||
source.setValue(newValue == null ? null : ViewModels.of(newValue));
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignTowardViewComplex(Property<?> controlProperty, Property<?> attribute) {
|
||||
Property<Object> control = (Property<Object>) controlProperty;
|
||||
Property<Object> source = (Property<Object>) attribute;
|
||||
control.setValue(modelBehind(source.getValue()));
|
||||
source.addListener((observable, oldValue, newValue) -> control.setValue(modelBehind(newValue)));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignTowardModelComplex(Property<?> controlProperty, Property<?> attribute) {
|
||||
Property<Object> control = (Property<Object>) controlProperty;
|
||||
Property<Object> source = (Property<Object>) attribute;
|
||||
control.setValue(modelBehind(source.getValue()));
|
||||
control.addListener((observable, oldValue, newValue) -> {
|
||||
if (modelBehind(source.getValue()) == newValue) {
|
||||
return;
|
||||
}
|
||||
source.setValue(newValue == null ? null : ViewModels.of(newValue));
|
||||
});
|
||||
}
|
||||
|
||||
private static Object modelBehind(Object value) {
|
||||
if (value instanceof ViewModel viewModel) {
|
||||
return viewModel.model();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void assignList(Property<?> controlProperty, ObservableList<?> values) {
|
||||
((Property<Object>) controlProperty).setValue(values);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignBoth(Property<?> controlProperty, Property<?> attribute) {
|
||||
((Property<Object>) controlProperty).bindBidirectional((Property<Object>) attribute);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignTowardView(Property<?> controlProperty, Property<?> attribute) {
|
||||
Property<Object> control = (Property<Object>) controlProperty;
|
||||
Property<Object> source = (Property<Object>) attribute;
|
||||
control.setValue(source.getValue());
|
||||
source.addListener((observable, oldValue, newValue) -> control.setValue(newValue));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void alignTowardModel(Property<?> controlProperty, Property<?> attribute) {
|
||||
Property<Object> control = (Property<Object>) controlProperty;
|
||||
Property<Object> target = (Property<Object>) attribute;
|
||||
control.setValue(target.getValue());
|
||||
control.addListener((observable, oldValue, newValue) -> target.setValue(newValue));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NamedNodeMap;
|
||||
import org.w3c.dom.Node;
|
||||
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
|
||||
/**
|
||||
* Reads the Controller class named by an FXML document.
|
||||
*/
|
||||
final class FxmlControllers {
|
||||
|
||||
private static final String FXML_NAMESPACE = "http://javafx.com/fxml";
|
||||
|
||||
private FxmlControllers() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Controller class named on the document root, or {@code null} when the document
|
||||
* names none.
|
||||
*
|
||||
* @param fxml the FXML document
|
||||
* @return the Controller class, or {@code null}
|
||||
*/
|
||||
static Class<?> controllerClass(URL fxml) {
|
||||
String name = controllerName(fxml);
|
||||
if (name == null || name.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Class.forName(name);
|
||||
} catch (ClassNotFoundException failure) {
|
||||
throw new IllegalArgumentException("No controller " + name, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String controllerName(URL fxml) {
|
||||
try (InputStream input = fxml.openStream()) {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
Element root = factory.newDocumentBuilder().parse(input).getDocumentElement();
|
||||
String named = root.getAttributeNS(FXML_NAMESPACE, "controller");
|
||||
if (named != null && !named.isEmpty()) {
|
||||
return named;
|
||||
}
|
||||
NamedNodeMap attributes = root.getAttributes();
|
||||
for (int index = 0; index < attributes.getLength(); index++) {
|
||||
Node attribute = attributes.item(index);
|
||||
if ("controller".equals(attribute.getLocalName())
|
||||
|| "fx:controller".equals(attribute.getNodeName())) {
|
||||
return attribute.getNodeValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (IOException | ParserConfigurationException | SAXException failure) {
|
||||
throw new IllegalStateException("Could not read controller from " + fxml, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* Receives the ViewModel selected for a Controller or a Component.
|
||||
* <p>
|
||||
* A Component is called after its model property is set to a non-null Model and before that
|
||||
* Component's bindings run.
|
||||
*/
|
||||
public interface HasViewModel {
|
||||
|
||||
/**
|
||||
* Receives the ViewModel for the Model this object is bound to.
|
||||
*
|
||||
* @param viewModel the selected ViewModel
|
||||
*/
|
||||
void setViewModel(ViewModel viewModel);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Holds the binding lists instantiated while one View is loading.
|
||||
* <p>
|
||||
* FXML objects declared in {@code fx:define} are not scene-graph children and have no
|
||||
* {@code fx:id} in the accepted document shape, so each {@link BindingList} registers
|
||||
* itself here from its constructor. A Component document opens a nested session, so its
|
||||
* lists stay off the parent session.
|
||||
*/
|
||||
final class LoadSession {
|
||||
|
||||
private static final ThreadLocal<Deque<LoadSession>> STACK = new ThreadLocal<>();
|
||||
|
||||
private final List<BindingList> bindingLists = new ArrayList<>();
|
||||
private final Set<Class<?>> built = new HashSet<>();
|
||||
|
||||
private LoadSession() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a session on this thread.
|
||||
*
|
||||
* @return the open session
|
||||
*/
|
||||
static LoadSession open() {
|
||||
Deque<LoadSession> stack = new ArrayDeque<>();
|
||||
LoadSession session = new LoadSession();
|
||||
stack.push(session);
|
||||
STACK.set(stack);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a nested session for a Component document.
|
||||
* <p>
|
||||
* Binding lists created until {@link #unnest()} register here, not on the parent session.
|
||||
* A Component created after the parent load has finished, such as a repeated child added
|
||||
* later, still gets a session so its own binding list can be captured.
|
||||
*
|
||||
* @return the nested session
|
||||
*/
|
||||
static LoadSession nest() {
|
||||
Deque<LoadSession> stack = STACK.get();
|
||||
if (stack == null) {
|
||||
stack = new ArrayDeque<>();
|
||||
STACK.set(stack);
|
||||
}
|
||||
LoadSession session = new LoadSession();
|
||||
stack.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the session that was current before {@link #nest()}.
|
||||
* <p>
|
||||
* When the nested session was the only one, the thread-local stack is dropped.
|
||||
*/
|
||||
static void unnest() {
|
||||
Deque<LoadSession> stack = STACK.get();
|
||||
stack.pop();
|
||||
if (stack.isEmpty()) {
|
||||
STACK.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the session for this thread.
|
||||
*/
|
||||
static void close() {
|
||||
STACK.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a binding list created while a session is open.
|
||||
*
|
||||
* @param bindingList the list FXML just constructed
|
||||
*/
|
||||
static void register(BindingList bindingList) {
|
||||
Deque<LoadSession> stack = STACK.get();
|
||||
if (stack == null || stack.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
stack.peek().bindingLists.add(bindingList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the first Component of {@code type} built during this load.
|
||||
* <p>
|
||||
* Nested documents share the outermost session, so a second Component of the same class
|
||||
* anywhere in the load is not the first.
|
||||
*
|
||||
* @param type the Component class
|
||||
* @return {@code true} when this is the first time that class is built during the load
|
||||
*/
|
||||
static boolean claimFirst(Class<?> type) {
|
||||
Deque<LoadSession> stack = STACK.get();
|
||||
if (stack == null || stack.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return stack.peekLast().built.add(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the lists registered during this session, in document order.
|
||||
*
|
||||
* @return the binding lists
|
||||
*/
|
||||
List<BindingList> bindingLists() {
|
||||
return bindingLists;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.scene.Parent;
|
||||
|
||||
/**
|
||||
* The scene-graph root and ViewModel produced by loading a View for a Model instance.
|
||||
*/
|
||||
public final class LoadedView {
|
||||
|
||||
private final Parent root;
|
||||
private final Object viewModel;
|
||||
|
||||
/**
|
||||
* Creates the result of one load.
|
||||
*
|
||||
* @param root the scene-graph root
|
||||
* @param viewModel the ViewModel built for the Model instance, or a replacement that exposes
|
||||
* JavaFX properties by name
|
||||
*/
|
||||
LoadedView(Parent root, Object viewModel) {
|
||||
this.root = root;
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scene-graph root.
|
||||
*
|
||||
* @return the root
|
||||
*/
|
||||
public Parent getRoot() {
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ViewModel the View is bound to.
|
||||
* <p>
|
||||
* A generic ViewModel, and a subclass of it, are returned as that ViewModel. A replacement
|
||||
* that does not extend {@link ViewModel} is addressed through {@link #property(String)}.
|
||||
*
|
||||
* @return the ViewModel
|
||||
* @throws IllegalStateException when the View is bound to a replacement
|
||||
*/
|
||||
public ViewModel getViewModel() {
|
||||
if (viewModel instanceof ViewModel bound) {
|
||||
return bound;
|
||||
}
|
||||
throw new IllegalStateException("View is bound to " + viewModel.getClass().getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JavaFX property for an Attribute name.
|
||||
* <p>
|
||||
* The property's class follows the Attribute's declared type. The name {@code latestMessage}
|
||||
* is a string property holding the text of the latest Message.
|
||||
*
|
||||
* @param name the Attribute name, {@code latestMessage}, or a JavaFX property declared on
|
||||
* the ViewModel
|
||||
* @return the property, or {@code null} when that name is not a read-write Attribute,
|
||||
* not {@code latestMessage}, and not a JavaFX property declared on the ViewModel
|
||||
*/
|
||||
public Property<?> property(String name) {
|
||||
if (viewModel instanceof ViewModel bound) {
|
||||
return bound.property(name);
|
||||
}
|
||||
return NamedProperties.get(viewModel, name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* A notice on a ViewModel with a severity, a text, and a source.
|
||||
*/
|
||||
public final class Message {
|
||||
|
||||
/**
|
||||
* How serious a notice is.
|
||||
*/
|
||||
public enum Severity {
|
||||
/** Informational notice. Not written to the log. */
|
||||
INFO,
|
||||
/** Warning. Not written to the log. */
|
||||
WARN,
|
||||
/** Error. Written to the log. */
|
||||
ERROR,
|
||||
/** Fatal error. Written to the log. */
|
||||
FATAL
|
||||
}
|
||||
|
||||
private final Severity severity;
|
||||
private String text;
|
||||
private final String source;
|
||||
private Message origin;
|
||||
|
||||
/**
|
||||
* Creates a notice.
|
||||
*
|
||||
* @param severity how serious the notice is
|
||||
* @param text the notice text
|
||||
* @param source where the notice came from, such as a binding Path
|
||||
*/
|
||||
public Message(Severity severity, String text, String source) {
|
||||
this.severity = severity;
|
||||
this.text = text;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how serious the notice is.
|
||||
*
|
||||
* @return the severity
|
||||
*/
|
||||
public Severity getSeverity() {
|
||||
return severity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the notice text.
|
||||
*
|
||||
* @return the text
|
||||
*/
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns where the notice came from.
|
||||
*
|
||||
* @return the source
|
||||
*/
|
||||
public String getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the text of a binding failure that is already in the list.
|
||||
*
|
||||
* @param text the new text
|
||||
*/
|
||||
void setText(String text) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy whose source is extended for a parent ViewModel.
|
||||
* <p>
|
||||
* The copy remembers this notice, so a cycle does not carry it back onto the ViewModel
|
||||
* that already holds it.
|
||||
*
|
||||
* @param source the extended source
|
||||
* @return the copy
|
||||
*/
|
||||
Message withExtendedSource(String source) {
|
||||
Message copy = new Message(severity, text, source);
|
||||
copy.origin = this;
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the notice this copy mirrors.
|
||||
*
|
||||
* @return that notice, or {@code null} when this notice was added on its own ViewModel
|
||||
*/
|
||||
Message origin() {
|
||||
return origin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* Hears a notice added, dropped, or edited on a ViewModel.
|
||||
*/
|
||||
interface MessageListener {
|
||||
|
||||
/**
|
||||
* A notice was added.
|
||||
*
|
||||
* @param message the notice
|
||||
*/
|
||||
void added(Message message);
|
||||
|
||||
/**
|
||||
* A notice was dropped.
|
||||
*
|
||||
* @param message the notice
|
||||
*/
|
||||
void removed(Message message);
|
||||
|
||||
/**
|
||||
* A notice kept its place and changed its text.
|
||||
*
|
||||
* @param message the notice
|
||||
*/
|
||||
void textChanged(Message message);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Dialog;
|
||||
import javafx.scene.control.Label;
|
||||
|
||||
/**
|
||||
* Shows each newly added {@link Message} in a dialog when the developer asks.
|
||||
* <p>
|
||||
* The library shows nothing until {@link #showNewMessages(ViewModel)} is called. An in-place
|
||||
* update of an existing binding failure is not a new Message, so it does not open another dialog.
|
||||
*/
|
||||
public final class MessagePopup {
|
||||
|
||||
private MessagePopup() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows each Message added to {@code viewModel} after this call in a dialog.
|
||||
* <p>
|
||||
* The dialog is shown with {@link Dialog#show()}, so the caller is not blocked. Messages
|
||||
* already on the ViewModel stay where they are. A later in-place update of a binding failure
|
||||
* does not open another dialog.
|
||||
*
|
||||
* @param viewModel the ViewModel whose new Messages should be shown
|
||||
*/
|
||||
public static void showNewMessages(ViewModel viewModel) {
|
||||
viewModel.whenMessageAdded(MessagePopup::show);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a dialog whose content is the Message text.
|
||||
*
|
||||
* @param message the notice to show
|
||||
*/
|
||||
private static void show(Message message) {
|
||||
Dialog<Void> dialog = new Dialog<>();
|
||||
dialog.setHeaderText(null);
|
||||
dialog.getDialogPane().setContent(new Label(message.getText()));
|
||||
dialog.getDialogPane().getButtonTypes().add(ButtonType.OK);
|
||||
dialog.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import javafx.beans.property.Property;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* Reads a JavaFX property declared on an object by name.
|
||||
* <p>
|
||||
* A name {@code badge} is the no-argument method {@code badgeProperty}.
|
||||
*/
|
||||
final class NamedProperties {
|
||||
|
||||
private NamedProperties() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JavaFX property named on {@code bean}.
|
||||
*
|
||||
* @param bean the object that declares the property
|
||||
* @param name the property name
|
||||
* @return the property, or {@code null} when {@code bean} declares none of that name
|
||||
*/
|
||||
static Property<?> get(Object bean, String name) {
|
||||
String methodName = name + "Property";
|
||||
try {
|
||||
Method method = bean.getClass().getMethod(methodName);
|
||||
if (!Property.class.isAssignableFrom(method.getReturnType())) {
|
||||
return null;
|
||||
}
|
||||
Object value = method.invoke(bean);
|
||||
if (value instanceof Property<?> property) {
|
||||
return property;
|
||||
}
|
||||
return null;
|
||||
} catch (NoSuchMethodException notDeclared) {
|
||||
return null;
|
||||
} catch (IllegalAccessException | InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Could not read property " + name, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* A repeat element declared in a {@link BindingList}.
|
||||
* <p>
|
||||
* The element names the pane by {@code fx:id}, the Component class, and the collection
|
||||
* Attribute. The library keeps one Component child in that pane for each Model instance, in
|
||||
* list order.
|
||||
*/
|
||||
public class Repeat {
|
||||
|
||||
private String pane;
|
||||
private String component;
|
||||
private String collection;
|
||||
|
||||
/**
|
||||
* Sets the {@code fx:id} of the pane that holds the Component children.
|
||||
*
|
||||
* @param pane the pane id
|
||||
*/
|
||||
public void setPane(String pane) {
|
||||
this.pane = pane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Component class name. Each element gets a new instance of that class.
|
||||
*
|
||||
* @param component the class name
|
||||
*/
|
||||
public void setComponent(String component) {
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the collection Attribute name. The list holds the Model instances.
|
||||
*
|
||||
* @param collection the attribute name
|
||||
*/
|
||||
public void setCollection(String collection) {
|
||||
this.collection = collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@code fx:id} of the pane.
|
||||
*
|
||||
* @return the pane id
|
||||
*/
|
||||
public String getPane() {
|
||||
return pane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Component class name.
|
||||
*
|
||||
* @return the class name
|
||||
*/
|
||||
public String getComponent() {
|
||||
return component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collection Attribute name.
|
||||
*
|
||||
* @return the attribute name
|
||||
*/
|
||||
public String getCollection() {
|
||||
return collection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.layout.Pane;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Keeps one Component child in a pane for each Model instance in a collection.
|
||||
*/
|
||||
final class RepeatApplier {
|
||||
|
||||
private RepeatApplier() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the named pane with one new Component for each element, in list order.
|
||||
* <p>
|
||||
* Each Component is a new injected instance. The cached singleton of that class is left
|
||||
* in place. The child's model property is the element. Adding or removing an element
|
||||
* adds or removes the matching child. The list is the one the entity holds, so a change
|
||||
* on either side is visible in the pane.
|
||||
*
|
||||
* @param namespace objects addressed by {@code fx:id}
|
||||
* @param viewModel the ViewModel for this View, or a replacement that exposes the collection by name
|
||||
* @param repeat the declaration
|
||||
* @param context the container that creates each Component
|
||||
*/
|
||||
static void apply(Map<String, Object> namespace, Object viewModel, Repeat repeat,
|
||||
ApplicationContext context) {
|
||||
Object named = namespace.get(repeat.getPane());
|
||||
if (!(named instanceof Pane pane)) {
|
||||
throw new IllegalArgumentException("No pane \"" + repeat.getPane() + "\"");
|
||||
}
|
||||
Property<?> property = collection(viewModel, repeat.getCollection());
|
||||
if (property == null || !(property.getValue() instanceof ObservableList<?> values)) {
|
||||
throw new IllegalArgumentException("No collection \"" + repeat.getCollection() + "\"");
|
||||
}
|
||||
Class<?> type = componentType(repeat.getComponent());
|
||||
for (Object element : values) {
|
||||
pane.getChildren().add(child(type, context, element));
|
||||
}
|
||||
listen(pane, type, context, values);
|
||||
}
|
||||
|
||||
private static Property<?> collection(Object viewModel, String name) {
|
||||
if (viewModel instanceof ViewModel model) {
|
||||
return model.property(name);
|
||||
}
|
||||
return NamedProperties.get(viewModel, name);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void listen(Pane pane, Class<?> type, ApplicationContext context, ObservableList<?> values) {
|
||||
ObservableList<Object> elements = (ObservableList<Object>) values;
|
||||
elements.addListener((ListChangeListener<Object>) change -> {
|
||||
while (change.next()) {
|
||||
if (change.wasPermutated() || change.wasUpdated()) {
|
||||
continue;
|
||||
}
|
||||
if (change.getRemovedSize() > 0) {
|
||||
pane.getChildren().remove(change.getFrom(), change.getFrom() + change.getRemovedSize());
|
||||
}
|
||||
for (int index = 0; index < change.getAddedSize(); index++) {
|
||||
pane.getChildren().add(
|
||||
change.getFrom() + index,
|
||||
child(type, context, change.getAddedSubList().get(index)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Node child(Class<?> type, ApplicationContext context, Object element) {
|
||||
Object component = Components.fresh(type, context);
|
||||
Components.setModel(component, element);
|
||||
return (Node) component;
|
||||
}
|
||||
|
||||
private static Class<?> componentType(String className) {
|
||||
try {
|
||||
return Class.forName(className);
|
||||
} catch (ClassNotFoundException failure) {
|
||||
throw new IllegalArgumentException("No component " + className, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
/**
|
||||
* Controller used when a document names none.
|
||||
* <p>
|
||||
* A View with only Direct bindings still loads. The library hands this controller the ViewModel
|
||||
* after construction and before bindings, the same way it hands a Controller the developer wrote.
|
||||
*/
|
||||
final class SuppliedController implements HasViewModel {
|
||||
|
||||
private ViewModel viewModel;
|
||||
|
||||
/**
|
||||
* Receives the ViewModel for this View.
|
||||
*
|
||||
* @param viewModel the ViewModel built for the Model instance
|
||||
*/
|
||||
@Override
|
||||
public void setViewModel(ViewModel viewModel) {
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ViewModel handed to this controller.
|
||||
*
|
||||
* @return the ViewModel, or {@code null} when none has been handed over
|
||||
*/
|
||||
ViewModel viewModel() {
|
||||
return viewModel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Parent;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
|
||||
/**
|
||||
* Loads a View for one Model instance.
|
||||
*/
|
||||
public final class ViewLoader {
|
||||
|
||||
private ViewLoader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a View for one Model instance.
|
||||
* <p>
|
||||
* Reuses the ViewModel already built for {@code model}, or builds one, then loads {@code fxml}
|
||||
* and applies binding elements declared in {@code fx:define}. A Computed binding writes a
|
||||
* Controller method result to a control and does not write the Model. Direction {@code both}
|
||||
* keeps the control and the Attribute
|
||||
* aligned, so editing the control writes the Attribute immediately. Direction {@code view}
|
||||
* shows the Attribute and follows later property changes without writing the Attribute back.
|
||||
* Direction {@code model} shows the current value, writes the Attribute when the control
|
||||
* changes, and does not push later property changes to the control. A Path of one name is an
|
||||
* Attribute of this View's ViewModel. Further names step into a nested ViewModel, and the
|
||||
* direction applies to the last name. When a Complex attribute along the Path is null, the
|
||||
* control and the Model keep their current values. A collection of simple values is one shared
|
||||
* list: the Direct binding sets the control property to that
|
||||
* {@link javafx.collections.ObservableList}, so an add or remove is visible on both sides.
|
||||
* A path that is not a read-write Attribute fails the load. The context creates a Controller
|
||||
* the document names, and injects that Controller's services. When the document names no
|
||||
* Controller, the library supplies one. A Controller that implements {@link HasViewModel}
|
||||
* receives the ViewModel after construction and before bindings. {@link ViewModelClass} on that
|
||||
* Controller names the ViewModel class. The library constructs it with the Model instance
|
||||
* before load. With no annotation, the library builds the generic ViewModel. The first View of a
|
||||
* Model instance fixes that class. A later View that names a different class fails the load,
|
||||
* and the earlier View stays as it was. A later View that names none reuses the ViewModel.
|
||||
* The context creates a
|
||||
* Converter when a Direct binding names one. A converter that cannot produce a value
|
||||
* keeps the previous value and records one error Message on the ViewModel that owns the last
|
||||
* Attribute, with that Attribute's name as the source. A parent carries a copy with the source
|
||||
* extended. It does not fail the load.
|
||||
* A parent document names a Component class as an element. The context creates that Component
|
||||
* and injects its services. A second element of the same class is another injected instance,
|
||||
* and the cached singleton stays the first one. The Component document {@code ClassName.fxml},
|
||||
* in the same package, is loaded with the Component as {@code fx:root} and as the Controller.
|
||||
* That document's bindings are not applied during this load. They run when the Component's
|
||||
* model property becomes a non-null Model. {@link ViewModelClass} on the Component names the
|
||||
* ViewModel class for that Model. A Component that implements {@link HasViewModel} receives
|
||||
* that ViewModel after the property is set and before those bindings run.
|
||||
* A {@link Repeat} in the same list names a pane, a Component class, and a collection
|
||||
* Attribute. One new Component child is placed in that pane for each Model instance, in list
|
||||
* order. Adding or removing an element updates that pane. Those instances are created with
|
||||
* {@link de.neitzel.injection.ApplicationContext#createComponent} so the cached singleton of
|
||||
* that class stays in place. A collection of Model instances is an
|
||||
* {@link javafx.collections.ObservableList} of those instances, shared with the entity.
|
||||
* Selection is a separate Attribute of the parent Model. Null means nothing is selected.
|
||||
* A Direct binding can tie a control property to that Attribute. The repeated Component does
|
||||
* not store the selection.
|
||||
* Loading does not open a popup or fill a status bar. A Direct binding whose path is
|
||||
* {@code latestMessage} shows the latest Message text. {@link MessagePopup#showNewMessages}
|
||||
* shows each newly added Message in a dialog.
|
||||
*
|
||||
* @param fxml the FXML document
|
||||
* @param model the Model instance
|
||||
* @param context the application context
|
||||
* @return the loaded View
|
||||
* @throws IllegalArgumentException when a Direct binding names a path that is not a read-write Attribute
|
||||
*/
|
||||
public static LoadedView load(URL fxml, Object model, ApplicationContext context) {
|
||||
Class<?> viewModelClass = ViewModels.namedOn(FxmlControllers.controllerClass(fxml));
|
||||
Object viewModel = ViewModels.of(model, viewModelClass);
|
||||
LoadSession session = LoadSession.open();
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(fxml);
|
||||
loader.setControllerFactory(type -> controller(context, type));
|
||||
loader.setBuilderFactory(type -> Components.builder(type, context));
|
||||
Object loaded = loader.load();
|
||||
if (!(loaded instanceof Parent root)) {
|
||||
throw new IllegalArgumentException("View root must be a parent");
|
||||
}
|
||||
Object controller = loader.getController();
|
||||
if (controller == null) {
|
||||
controller = new SuppliedController();
|
||||
}
|
||||
if (controller instanceof HasViewModel hasViewModel && viewModel instanceof ViewModel bound) {
|
||||
hasViewModel.setViewModel(bound);
|
||||
}
|
||||
for (BindingList bindingList : session.bindingLists()) {
|
||||
bindingList.apply(loader.getNamespace(), viewModel, controller, root, context);
|
||||
}
|
||||
return new LoadedView(root, viewModel);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Could not load view " + fxml, failure);
|
||||
} finally {
|
||||
LoadSession.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static Object controller(ApplicationContext context, Class<?> type) {
|
||||
return context.getComponent(type.asSubclass(Object.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.FloatProperty;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.LongProperty;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.property.SimpleFloatProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.beans.property.SimpleLongProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.beans.BeanInfo;
|
||||
import java.beans.IntrospectionException;
|
||||
import java.beans.Introspector;
|
||||
import java.beans.PropertyDescriptor;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
import java.lang.reflect.WildcardType;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.time.temporal.TemporalAmount;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The object a View binds to, built at runtime for one Model instance.
|
||||
* <p>
|
||||
* Each read-write Attribute that is not declared on {@link Object} is stored as a JavaFX
|
||||
* property under the Attribute name. The property class follows the Attribute's declared type,
|
||||
* including when the current value is null. String, integer, long, float, double, and boolean
|
||||
* attributes use the matching JavaFX property. Any other declared type is an object property.
|
||||
* A Complex attribute, a read-write Attribute whose declared type itself has a read-write
|
||||
* Attribute, stores the ViewModel for the nested Model instance, or null when the Attribute
|
||||
* is null. Strings, numbers, booleans, enums, {@link Date}, {@link Calendar}, and {@code java.time}
|
||||
* values stay simple even when that type has setters. A collection of Model instances is not a
|
||||
* Complex attribute. It is an object property whose value is an {@link ObservableList} of those
|
||||
* Model instances, not of ViewModels. A list, set, or collection of simple values is an object
|
||||
* property whose value is an {@link ObservableList} of those values. When either attribute's
|
||||
* current value is a list, the observable list is backed by it and stored on the entity when the
|
||||
* setter accepts it, so both sides hold one list. A cycle, or another Attribute that reaches the
|
||||
* same instance, reuses that ViewModel.
|
||||
* The property is set from the Attribute when the ViewModel is created. A null number or
|
||||
* boolean leaves the primitive property at its default. After that, a change of the property
|
||||
* writes the Attribute. A Complex attribute writes the Model instance behind the ViewModel.
|
||||
* A call to the entity's setter does not update the property.
|
||||
* <p>
|
||||
* Beside those attributes, the ViewModel keeps a list of {@link Message}s and the latest one.
|
||||
* The developer can add a message of any severity. Error and fatal messages are written to the log.
|
||||
* Info and warn are not. A parent carries each current child message, with the source extended by
|
||||
* the Attribute that leads to that child. Each further parent prepends its own Attribute. The
|
||||
* carried copy is not logged again. When the child drops the message, the parent drops that copy.
|
||||
* The name {@code latestMessage} is a string property holding the text of the latest Message, so a
|
||||
* Direct binding can show that text on a control. The library does not open a popup or fill a
|
||||
* status bar unless the developer asks. {@link MessagePopup#showNewMessages} shows each newly added
|
||||
* Message in a dialog. An in-place update does not.
|
||||
*/
|
||||
public class ViewModel {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ViewModel.class);
|
||||
|
||||
private final Object model;
|
||||
private final Map<String, Property<?>> properties = new HashMap<>();
|
||||
private final Set<String> complexAttributes = new HashSet<>();
|
||||
private final List<Message> messages = new ArrayList<>();
|
||||
private final Map<String, Message> bindingFailures = new HashMap<>();
|
||||
private final List<MessageListener> messageListeners = new ArrayList<>();
|
||||
private final Map<String, ChildMessageMirror> childMirrors = new HashMap<>();
|
||||
private final StringProperty latestMessageText = new SimpleStringProperty();
|
||||
private final List<Consumer<Message>> messageAdded = new ArrayList<>();
|
||||
private Message latestMessage;
|
||||
|
||||
/**
|
||||
* Builds a ViewModel for one Model instance.
|
||||
* <p>
|
||||
* A subclass in another package calls this constructor and may add JavaFX properties under
|
||||
* new names. A Binding element addresses those names the same way it addresses an Attribute.
|
||||
*
|
||||
* @param model the Model instance
|
||||
*/
|
||||
protected ViewModel(Object model) {
|
||||
this.model = model;
|
||||
ViewModels.register(model, this);
|
||||
for (PropertyDescriptor descriptor : descriptors(model.getClass())) {
|
||||
Method getter = descriptor.getReadMethod();
|
||||
Method setter = descriptor.getWriteMethod();
|
||||
if (getter == null || setter == null || descriptor.getPropertyType() == null) {
|
||||
continue;
|
||||
}
|
||||
String name = descriptor.getName();
|
||||
Class<?> type = descriptor.getPropertyType();
|
||||
Object current = read(getter, name);
|
||||
if (isComplex(type)) {
|
||||
LazyComplex property = new LazyComplex(current);
|
||||
watchComplex(property, setter, name);
|
||||
property.attached();
|
||||
properties.put(name, property);
|
||||
complexAttributes.add(name);
|
||||
} else {
|
||||
if (isSimpleValueCollection(getter) || isModelCollection(getter)) {
|
||||
current = shareSimpleList(setter, name, current);
|
||||
}
|
||||
Property<?> property = createProperty(type, current);
|
||||
watch(property, setter, name);
|
||||
properties.put(name, property);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JavaFX property for an Attribute name.
|
||||
* <p>
|
||||
* The name {@code latestMessage} is the text of the latest {@link Message}, beside the
|
||||
* Model attributes. It is a string property. A Direct binding can read it. An in-place
|
||||
* update of that Message refreshes the text and does not replace the Message.
|
||||
*
|
||||
* @param name the Attribute name, {@code latestMessage}, or a JavaFX property declared on
|
||||
* this ViewModel
|
||||
* @return the property, or {@code null} when that name is not a read-write Attribute,
|
||||
* not {@code latestMessage}, and not a JavaFX property declared on this ViewModel
|
||||
*/
|
||||
public Property<?> property(String name) {
|
||||
if ("latestMessage".equals(name)) {
|
||||
return latestMessageText;
|
||||
}
|
||||
Property<?> stored = properties.get(name);
|
||||
if (stored != null) {
|
||||
return stored;
|
||||
}
|
||||
return declaredProperty(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Model instance this ViewModel was built for.
|
||||
*
|
||||
* @return the Model instance
|
||||
*/
|
||||
Object model() {
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether an Attribute is complex.
|
||||
*
|
||||
* @param name the Attribute name
|
||||
* @return {@code true} when that Attribute holds a nested ViewModel
|
||||
*/
|
||||
boolean isComplex(String name) {
|
||||
return complexAttributes.contains(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls {@code listener} when any Attribute property changes.
|
||||
*
|
||||
* @param listener the listener
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
void addAttributeListener(ChangeListener<Object> listener) {
|
||||
for (Property<?> property : properties.values()) {
|
||||
((Property<Object>) property).addListener(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a JavaFX property a subclass declares under {@code name}, such as {@code titleProperty}.
|
||||
*
|
||||
* @param name the property name
|
||||
* @return the property, or {@code null} when this class declares none
|
||||
*/
|
||||
private Property<?> declaredProperty(String name) {
|
||||
return NamedProperties.get(this, name);
|
||||
}
|
||||
|
||||
private static List<PropertyDescriptor> descriptors(Class<?> type) {
|
||||
try {
|
||||
BeanInfo info = Introspector.getBeanInfo(type, Object.class);
|
||||
return List.of(info.getPropertyDescriptors());
|
||||
} catch (IntrospectionException failure) {
|
||||
throw new IllegalStateException("Could not introspect " + type.getName(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isComplex(Class<?> type) {
|
||||
if (simpleValue(type)) {
|
||||
return false;
|
||||
}
|
||||
for (PropertyDescriptor descriptor : descriptors(type)) {
|
||||
if (descriptor.getReadMethod() != null
|
||||
&& descriptor.getWriteMethod() != null
|
||||
&& descriptor.getPropertyType() != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean simpleValue(Class<?> type) {
|
||||
if (type.isPrimitive() || type.isEnum() || type.isArray()
|
||||
|| type == String.class || type == Boolean.class) {
|
||||
return true;
|
||||
}
|
||||
if (Collection.class.isAssignableFrom(type) || Map.class.isAssignableFrom(type)) {
|
||||
return true;
|
||||
}
|
||||
if (Number.class.isAssignableFrom(type)) {
|
||||
return true;
|
||||
}
|
||||
if (Date.class.isAssignableFrom(type) || Calendar.class.isAssignableFrom(type)) {
|
||||
return true;
|
||||
}
|
||||
Package packaged = type.getPackage();
|
||||
return packaged != null && packaged.getName().startsWith("java.time");
|
||||
}
|
||||
|
||||
private static Property<?> createProperty(Class<?> type, Object value) {
|
||||
if (type == String.class) {
|
||||
return new SimpleStringProperty((String) value);
|
||||
}
|
||||
if (type == Integer.class || type == int.class) {
|
||||
return integerProperty(value);
|
||||
}
|
||||
if (type == Long.class || type == long.class) {
|
||||
return longProperty(value);
|
||||
}
|
||||
if (type == Float.class || type == float.class) {
|
||||
return floatProperty(value);
|
||||
}
|
||||
if (type == Double.class || type == double.class) {
|
||||
return doubleProperty(value);
|
||||
}
|
||||
if (type == Boolean.class || type == boolean.class) {
|
||||
return booleanProperty(value);
|
||||
}
|
||||
return new SimpleObjectProperty<>(value);
|
||||
}
|
||||
|
||||
private static IntegerProperty integerProperty(Object value) {
|
||||
SimpleIntegerProperty property = new SimpleIntegerProperty();
|
||||
if (value != null) {
|
||||
property.setValue((Integer) value);
|
||||
}
|
||||
return property;
|
||||
}
|
||||
|
||||
private static LongProperty longProperty(Object value) {
|
||||
SimpleLongProperty property = new SimpleLongProperty();
|
||||
if (value != null) {
|
||||
property.setValue((Long) value);
|
||||
}
|
||||
return property;
|
||||
}
|
||||
|
||||
private static FloatProperty floatProperty(Object value) {
|
||||
SimpleFloatProperty property = new SimpleFloatProperty();
|
||||
if (value != null) {
|
||||
property.setValue((Float) value);
|
||||
}
|
||||
return property;
|
||||
}
|
||||
|
||||
private static DoubleProperty doubleProperty(Object value) {
|
||||
SimpleDoubleProperty property = new SimpleDoubleProperty();
|
||||
if (value != null) {
|
||||
property.setValue((Double) value);
|
||||
}
|
||||
return property;
|
||||
}
|
||||
|
||||
private static BooleanProperty booleanProperty(Object value) {
|
||||
SimpleBooleanProperty property = new SimpleBooleanProperty();
|
||||
if (value != null) {
|
||||
property.setValue((Boolean) value);
|
||||
}
|
||||
return property;
|
||||
}
|
||||
|
||||
private Object read(Method getter, String name) {
|
||||
try {
|
||||
return getter.invoke(model);
|
||||
} catch (IllegalAccessException | InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Could not read attribute " + name, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void watch(Property<?> property, Method setter, String name) {
|
||||
property.addListener((observable, oldValue, newValue) -> write(setter, name, newValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds a Complex attribute and builds its ViewModel the first time the value is read.
|
||||
* <p>
|
||||
* Attaching the change listener reads the value. That read is ignored until
|
||||
* {@link #attached()} so a Component annotation can name the class first.
|
||||
*/
|
||||
private static final class LazyComplex extends SimpleObjectProperty<Object> {
|
||||
|
||||
private final Object current;
|
||||
private boolean realized;
|
||||
private boolean attaching = true;
|
||||
|
||||
private LazyComplex(Object current) {
|
||||
this.current = current;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object get() {
|
||||
if (!attaching && !realized) {
|
||||
realized = true;
|
||||
super.set(current == null ? null : ViewModels.of(current));
|
||||
}
|
||||
return super.get();
|
||||
}
|
||||
|
||||
private void attached() {
|
||||
attaching = false;
|
||||
}
|
||||
|
||||
private boolean pending(Object instance) {
|
||||
return !realized && current == instance;
|
||||
}
|
||||
}
|
||||
|
||||
private void watchComplex(Property<?> property, Method setter, String name) {
|
||||
property.addListener((observable, oldValue, newValue) -> {
|
||||
if (oldValue != newValue) {
|
||||
detachChild(name);
|
||||
}
|
||||
Object written = newValue instanceof ViewModel viewModel ? viewModel.model() : newValue;
|
||||
write(setter, name, written);
|
||||
if (oldValue != newValue && newValue instanceof ViewModel child) {
|
||||
attachChild(name, child);
|
||||
realizeShared(child.model());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries the child ViewModel's current notices, extending each source with this Attribute.
|
||||
*
|
||||
* @param name the Attribute that leads to the child
|
||||
* @param child the child ViewModel
|
||||
*/
|
||||
/**
|
||||
* Realizes every other Complex attribute that holds the same Model instance.
|
||||
* <p>
|
||||
* Home and work can name one address. Reading one still carries a notice under both
|
||||
* attribute names.
|
||||
*
|
||||
* @param instance the Model instance just selected
|
||||
*/
|
||||
private void realizeShared(Object instance) {
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
for (Property<?> property : properties.values()) {
|
||||
if (property instanceof LazyComplex lazy && lazy.pending(instance)) {
|
||||
lazy.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* @param name the Attribute
|
||||
*/
|
||||
private void detachChild(String name) {
|
||||
ChildMessageMirror mirror = childMirrors.remove(name);
|
||||
if (mirror != null) {
|
||||
mirror.detach();
|
||||
}
|
||||
}
|
||||
|
||||
private void write(Method setter, String name, Object value) {
|
||||
try {
|
||||
setter.invoke(model, value);
|
||||
} catch (IllegalAccessException | InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Could not write attribute " + name, failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the notices on this ViewModel, beside the Model attributes.
|
||||
*
|
||||
* @return the messages, oldest first
|
||||
*/
|
||||
public List<Message> getMessages() {
|
||||
return Collections.unmodifiableList(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the notice most recently added.
|
||||
* <p>
|
||||
* An in-place update of a binding failure does not change which notice is latest.
|
||||
*
|
||||
* @return the latest notice, or {@code null} when there is none
|
||||
*/
|
||||
public Message getLatestMessage() {
|
||||
return latestMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a notice of any severity.
|
||||
*
|
||||
* @param message the notice
|
||||
*/
|
||||
public void addMessage(Message message) {
|
||||
messages.add(message);
|
||||
latestMessage = message;
|
||||
publishLatestMessage();
|
||||
logSevere(message);
|
||||
notifyAdded(message);
|
||||
for (Consumer<Message> listener : List.copyOf(messageAdded)) {
|
||||
listener.accept(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls {@code listener} each time a Message is added.
|
||||
* <p>
|
||||
* An in-place update of a binding failure does not call the listener.
|
||||
*
|
||||
* @param listener the listener
|
||||
*/
|
||||
void whenMessageAdded(Consumer<Message> listener) {
|
||||
messageAdded.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a copy of a child notice. The copy is not written to the log.
|
||||
*
|
||||
* @param copy the carried notice
|
||||
*/
|
||||
void addCarriedMessage(Message copy) {
|
||||
messages.add(copy);
|
||||
latestMessage = copy;
|
||||
publishLatestMessage();
|
||||
notifyAdded(copy);
|
||||
for (Consumer<Message> listener : List.copyOf(messageAdded)) {
|
||||
listener.accept(copy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a carried copy. When it was the latest notice, the previous one becomes latest.
|
||||
*
|
||||
* @param copy the carried notice
|
||||
*/
|
||||
void removeCarriedMessage(Message copy) {
|
||||
if (!messages.remove(copy)) {
|
||||
return;
|
||||
}
|
||||
if (latestMessage == copy) {
|
||||
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
|
||||
publishLatestMessage();
|
||||
}
|
||||
notifyRemoved(copy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hears notices added on this ViewModel, including carried copies, and hears when they are
|
||||
* dropped or their text changes.
|
||||
*
|
||||
* @param listener the listener
|
||||
*/
|
||||
void addMessageListener(MessageListener listener) {
|
||||
messageListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops hearing notices added on this ViewModel.
|
||||
*
|
||||
* @param listener the listener
|
||||
*/
|
||||
void removeMessageListener(MessageListener listener) {
|
||||
messageListeners.remove(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells listeners that a notice was added.
|
||||
*
|
||||
* @param message the notice
|
||||
*/
|
||||
private void notifyAdded(Message message) {
|
||||
for (MessageListener listener : List.copyOf(messageListeners)) {
|
||||
listener.added(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells listeners that a notice kept its place and changed its text.
|
||||
* <p>
|
||||
* An in-place change does not log again and does not become a new latest notice.
|
||||
*
|
||||
* @param message the notice
|
||||
*/
|
||||
void noteMessageText(Message message) {
|
||||
if (message == latestMessage) {
|
||||
publishLatestMessage();
|
||||
}
|
||||
notifyTextChanged(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells listeners that a notice was dropped.
|
||||
*
|
||||
* @param message the notice
|
||||
*/
|
||||
private void notifyRemoved(Message message) {
|
||||
for (MessageListener listener : List.copyOf(messageListeners)) {
|
||||
listener.removed(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells listeners that a notice kept its place and changed its text.
|
||||
*
|
||||
* @param message the notice
|
||||
*/
|
||||
private void notifyTextChanged(Message message) {
|
||||
for (MessageListener listener : List.copyOf(messageListeners)) {
|
||||
listener.textChanged(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records one error whose source is an Attribute name. A further failure of that Attribute
|
||||
* replaces the text of the same notice. Parents that carry the notice hear the new text.
|
||||
*
|
||||
* @param path the Attribute name that owns the failure
|
||||
* @param text the error text
|
||||
*/
|
||||
void reportBindingFailure(String path, String text) {
|
||||
Message existing = bindingFailures.get(path);
|
||||
if (existing != null) {
|
||||
existing.setText(text);
|
||||
if (existing == latestMessage) {
|
||||
publishLatestMessage();
|
||||
}
|
||||
logSevere(existing);
|
||||
notifyTextChanged(existing);
|
||||
return;
|
||||
}
|
||||
Message message = new Message(Message.Severity.ERROR, text, path);
|
||||
bindingFailures.put(path, message);
|
||||
addMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the error recorded for an Attribute after a later conversion succeeds.
|
||||
*
|
||||
* @param path the Attribute name that owned the failure
|
||||
*/
|
||||
void clearBindingFailure(String path) {
|
||||
Message existing = bindingFailures.remove(path);
|
||||
if (existing == null) {
|
||||
return;
|
||||
}
|
||||
messages.remove(existing);
|
||||
if (latestMessage == existing) {
|
||||
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
|
||||
publishLatestMessage();
|
||||
}
|
||||
notifyRemoved(existing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the string property for {@code latestMessage} match {@link #getLatestMessage()}.
|
||||
*/
|
||||
private void publishLatestMessage() {
|
||||
latestMessageText.set(latestMessage == null ? null : latestMessage.getText());
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an error or fatal notice to the log. Info and warn are left out.
|
||||
*
|
||||
* @param message the notice
|
||||
*/
|
||||
private static void logSevere(Message message) {
|
||||
Message.Severity severity = message.getSeverity();
|
||||
if (severity == Message.Severity.ERROR || severity == Message.Severity.FATAL) {
|
||||
LOGGER.error(message.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSimpleValueCollection(Method getter) {
|
||||
if (!Collection.class.isAssignableFrom(getter.getReturnType())) {
|
||||
return false;
|
||||
}
|
||||
Class<?> element = elementType(getter.getGenericReturnType());
|
||||
return element != null && isSimpleValue(element);
|
||||
}
|
||||
|
||||
private static boolean isModelCollection(Method getter) {
|
||||
if (!Collection.class.isAssignableFrom(getter.getReturnType())) {
|
||||
return false;
|
||||
}
|
||||
Class<?> element = elementType(getter.getGenericReturnType());
|
||||
return element != null && isComplex(element);
|
||||
}
|
||||
|
||||
private static Class<?> elementType(Type type) {
|
||||
if (!(type instanceof ParameterizedType parameterized) || parameterized.getActualTypeArguments().length != 1) {
|
||||
return null;
|
||||
}
|
||||
return classOf(parameterized.getActualTypeArguments()[0]);
|
||||
}
|
||||
|
||||
private static Class<?> classOf(Type type) {
|
||||
if (type instanceof Class<?> raw) {
|
||||
return raw;
|
||||
}
|
||||
if (type instanceof ParameterizedType parameterized && parameterized.getRawType() instanceof Class<?> raw) {
|
||||
return raw;
|
||||
}
|
||||
if (type instanceof WildcardType wildcard && wildcard.getUpperBounds().length == 1) {
|
||||
return classOf(wildcard.getUpperBounds()[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isSimpleValue(Class<?> type) {
|
||||
if (type == String.class || type == Boolean.class || type == boolean.class || type.isEnum()) {
|
||||
return true;
|
||||
}
|
||||
if (type.isPrimitive()) {
|
||||
return type != char.class && type != void.class;
|
||||
}
|
||||
if (Number.class.isAssignableFrom(type)) {
|
||||
return true;
|
||||
}
|
||||
if (Date.class.isAssignableFrom(type) || Calendar.class.isAssignableFrom(type)) {
|
||||
return true;
|
||||
}
|
||||
return TemporalAccessor.class.isAssignableFrom(type) || TemporalAmount.class.isAssignableFrom(type);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Object shareSimpleList(Method setter, String name, Object current) {
|
||||
if (!(current instanceof Collection<?> collection)) {
|
||||
return current;
|
||||
}
|
||||
ObservableList<Object> observable = current instanceof List<?> list
|
||||
? FXCollections.observableList((List<Object>) list)
|
||||
: FXCollections.observableArrayList((Collection<Object>) collection);
|
||||
if (setter.getParameterTypes()[0].isAssignableFrom(observable.getClass())) {
|
||||
write(setter, name, observable);
|
||||
}
|
||||
return observable;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Names the ViewModel class for a Controller or a Component.
|
||||
* <p>
|
||||
* The library constructs that class with the Model instance for the first View of the instance.
|
||||
* A later View that names a different class fails to load. A later View that names none reuses
|
||||
* the ViewModel already built. The container does not create the ViewModel.
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ViewModelClass {
|
||||
|
||||
/**
|
||||
* Returns the ViewModel class.
|
||||
* <p>
|
||||
* The class has a constructor that takes the Model instance. It may extend {@link ViewModel}
|
||||
* or be another object that exposes JavaFX properties by name.
|
||||
*
|
||||
* @return the ViewModel class
|
||||
*/
|
||||
Class<?> value();
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The ViewModels already built, one for each Model instance.
|
||||
* <p>
|
||||
* Instances are compared by identity, so two Models that are equal still get two ViewModels.
|
||||
*/
|
||||
final class ViewModels {
|
||||
|
||||
private static final Map<Object, Object> BY_INSTANCE = new IdentityHashMap<>();
|
||||
|
||||
private ViewModels() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ViewModel for a Model instance, building the generic one when none exists yet.
|
||||
*
|
||||
* @param model the Model instance
|
||||
* @return the ViewModel for that instance, or a replacement that exposes JavaFX properties by name
|
||||
*/
|
||||
static Object of(Object model) {
|
||||
return of(model, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ViewModel for a Model instance.
|
||||
* <p>
|
||||
* Builds {@code viewModelClass} with the Model when none exists yet. A null class, or
|
||||
* {@link ViewModel} itself, builds the generic ViewModel. The class may extend {@link ViewModel}
|
||||
* or be a replacement that exposes JavaFX properties by name. The container does not create it.
|
||||
* The first View fixes the class. A later View that names a different class fails. A later
|
||||
* View that names none reuses the ViewModel.
|
||||
*
|
||||
* @param model the Model instance
|
||||
* @param viewModelClass the class named by {@link ViewModelClass}, or {@code null}
|
||||
* @return the ViewModel for that instance, or the replacement
|
||||
*/
|
||||
static Object of(Object model, Class<?> viewModelClass) {
|
||||
Object existing = BY_INSTANCE.get(model);
|
||||
if (existing != null) {
|
||||
if (viewModelClass != null && viewModelClass != ViewModel.class
|
||||
&& existing.getClass() != viewModelClass) {
|
||||
throw new IllegalArgumentException(
|
||||
"ViewModel is " + existing.getClass().getName()
|
||||
+ " but the View names " + viewModelClass.getName());
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
if (viewModelClass == null || viewModelClass == ViewModel.class) {
|
||||
return new ViewModel(model);
|
||||
}
|
||||
return construct(model, viewModelClass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ViewModel class named on a Controller or Component, or {@code null} when the
|
||||
* type names none.
|
||||
*
|
||||
* @param type the Controller or Component class
|
||||
* @return the named class, or {@code null}
|
||||
*/
|
||||
static Class<?> namedOn(Class<?> type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
ViewModelClass annotation = type.getAnnotation(ViewModelClass.class);
|
||||
if (annotation == null) {
|
||||
return null;
|
||||
}
|
||||
return annotation.value();
|
||||
}
|
||||
|
||||
private static Object construct(Object model, Class<?> viewModelClass) {
|
||||
Constructor<?> constructor = constructor(model, viewModelClass);
|
||||
constructor.setAccessible(true);
|
||||
try {
|
||||
Object created = constructor.newInstance(model);
|
||||
if (!(created instanceof ViewModel)) {
|
||||
register(model, created);
|
||||
}
|
||||
return created;
|
||||
} catch (InvocationTargetException failure) {
|
||||
if (failure.getCause() instanceof RuntimeException runtime) {
|
||||
throw runtime;
|
||||
}
|
||||
throw new IllegalStateException("Could not construct " + viewModelClass.getName(), failure);
|
||||
} catch (IllegalAccessException | InstantiationException failure) {
|
||||
throw new IllegalStateException("Could not construct " + viewModelClass.getName(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Constructor<?> constructor(Object model, Class<?> viewModelClass) {
|
||||
Constructor<?> chosen = null;
|
||||
for (Constructor<?> candidate : viewModelClass.getDeclaredConstructors()) {
|
||||
if (candidate.getParameterCount() != 1) {
|
||||
continue;
|
||||
}
|
||||
Class<?> parameter = candidate.getParameterTypes()[0];
|
||||
if (!parameter.isAssignableFrom(model.getClass())) {
|
||||
continue;
|
||||
}
|
||||
if (chosen == null || chosen.getParameterTypes()[0].isAssignableFrom(parameter)) {
|
||||
chosen = candidate;
|
||||
}
|
||||
}
|
||||
if (chosen == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"No constructor taking the Model on " + viewModelClass.getName());
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a ViewModel before its Attributes are walked, so a cycle reuses it.
|
||||
*
|
||||
* @param model the Model instance
|
||||
* @param viewModel the ViewModel under construction
|
||||
*/
|
||||
static void register(Object model, Object viewModel) {
|
||||
BY_INSTANCE.put(model, viewModel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.fixture.Address;
|
||||
import de.neitzel.fx.view.fixture.Household;
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.scene.control.TextField;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
class CarryChildMessageTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aStreetNoticeOnTheAddressAppearsOnThePersonAsAddressStreet() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
URL fxml = CarryChildMessageTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
ViewModel personModel = loaded.getViewModel();
|
||||
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||
addressModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
|
||||
|
||||
assertEquals(1, addressModel.getMessages().size());
|
||||
assertEquals("street", addressModel.getMessages().get(0).getSource());
|
||||
assertEquals(1, personModel.getMessages().size());
|
||||
Message carried = personModel.getMessages().get(0);
|
||||
assertEquals("address.street", carried.getSource());
|
||||
assertEquals("lane is closed", carried.getText());
|
||||
assertEquals(Message.Severity.INFO, carried.getSeverity());
|
||||
assertNotSame(addressModel.getMessages().get(0), carried);
|
||||
assertEquals(carried, personModel.getLatestMessage());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNestedStreetNoticeReadsAsPersonAddressStreet() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
Household household = new Household();
|
||||
household.setPerson(person);
|
||||
URL fxml = CarryChildMessageTest.class.getResource("plain-view.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, household, context);
|
||||
ViewModel householdModel = loaded.getViewModel();
|
||||
ViewModel personModel = (ViewModel) householdModel.property("person").getValue();
|
||||
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||
addressModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
|
||||
|
||||
assertEquals("street", addressModel.getMessages().get(0).getSource());
|
||||
assertEquals("address.street", personModel.getMessages().get(0).getSource());
|
||||
assertEquals(1, householdModel.getMessages().size());
|
||||
Message carried = householdModel.getMessages().get(0);
|
||||
assertEquals("person.address.street", carried.getSource());
|
||||
assertEquals("lane is closed", carried.getText());
|
||||
assertEquals(Message.Severity.INFO, carried.getSeverity());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBadStreetIsOwnedByTheAddressAndCarriedAsAddressStreet() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
URL fxml = CarryChildMessageTest.class.getResource("address-street-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
streetField.setText("nope");
|
||||
|
||||
assertEquals("Main", person.getAddress().getStreet());
|
||||
assertEquals("nope", streetField.getText());
|
||||
ViewModel personModel = loaded.getViewModel();
|
||||
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||
assertEquals(1, addressModel.getMessages().size());
|
||||
Message owned = addressModel.getMessages().get(0);
|
||||
assertEquals(Message.Severity.ERROR, owned.getSeverity());
|
||||
assertEquals("street", owned.getSource());
|
||||
assertEquals("not a street: nope", owned.getText());
|
||||
assertEquals(1, personModel.getMessages().size());
|
||||
Message carried = personModel.getMessages().get(0);
|
||||
assertEquals("address.street", carried.getSource());
|
||||
assertEquals("not a street: nope", carried.getText());
|
||||
assertEquals(Message.Severity.ERROR, carried.getSeverity());
|
||||
assertNotSame(owned, carried);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFixedStreetDropsTheNoticeFromTheAddressAndThePerson() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
URL fxml = CarryChildMessageTest.class.getResource("address-street-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
streetField.setText("nope");
|
||||
streetField.setText("ok Oak");
|
||||
|
||||
assertEquals("Oak", person.getAddress().getStreet());
|
||||
ViewModel personModel = loaded.getViewModel();
|
||||
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||
assertEquals(0, addressModel.getMessages().size());
|
||||
assertNull(addressModel.getLatestMessage());
|
||||
assertEquals(0, personModel.getMessages().size());
|
||||
assertNull(personModel.getLatestMessage());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void anotherBadStreetUpdatesTheCarriedNoticeInPlace() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
Household household = new Household();
|
||||
household.setPerson(person);
|
||||
URL fxml = CarryChildMessageTest.class.getResource("household-street-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, household, context);
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
streetField.setText("nope");
|
||||
|
||||
ViewModel householdModel = loaded.getViewModel();
|
||||
ViewModel personModel = (ViewModel) householdModel.property("person").getValue();
|
||||
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||
assertEquals(1, addressModel.getMessages().size());
|
||||
assertEquals(1, personModel.getMessages().size());
|
||||
assertEquals(1, householdModel.getMessages().size());
|
||||
Message owned = addressModel.getMessages().get(0);
|
||||
Message personCopy = personModel.getMessages().get(0);
|
||||
Message householdCopy = householdModel.getMessages().get(0);
|
||||
assertEquals("street", owned.getSource());
|
||||
assertEquals("address.street", personCopy.getSource());
|
||||
assertEquals("person.address.street", householdCopy.getSource());
|
||||
|
||||
streetField.setText("still-bad");
|
||||
|
||||
assertEquals("Main", person.getAddress().getStreet());
|
||||
assertEquals(1, addressModel.getMessages().size());
|
||||
assertEquals(1, personModel.getMessages().size());
|
||||
assertEquals(1, householdModel.getMessages().size());
|
||||
assertSame(owned, addressModel.getMessages().get(0));
|
||||
assertSame(personCopy, personModel.getMessages().get(0));
|
||||
assertSame(householdCopy, householdModel.getMessages().get(0));
|
||||
assertEquals("not a street: still-bad", owned.getText());
|
||||
assertEquals("not a street: still-bad", personCopy.getText());
|
||||
assertEquals("not a street: still-bad", householdCopy.getText());
|
||||
assertSame(householdCopy, householdModel.getLatestMessage());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearingTheAddressDropsTheCarriedNoticeAndANewAddressBringsItsOwn() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
URL fxml = CarryChildMessageTest.class.getResource("plain-view.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
ViewModel personModel = loaded.getViewModel();
|
||||
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||
addressModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
|
||||
assertEquals("address.street", personModel.getMessages().get(0).getSource());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Property<Object> address = (Property<Object>) personModel.property("address");
|
||||
address.setValue(null);
|
||||
|
||||
assertNull(person.getAddress());
|
||||
assertEquals(0, personModel.getMessages().size());
|
||||
assertNull(personModel.getLatestMessage());
|
||||
assertEquals(1, addressModel.getMessages().size());
|
||||
assertEquals("lane is closed", addressModel.getMessages().get(0).getText());
|
||||
|
||||
Address moved = new Address("Oak");
|
||||
LoadedView movedView = ViewLoader.load(fxml, moved, context);
|
||||
movedView.getViewModel().addMessage(new Message(Message.Severity.WARN, "under repair", "street"));
|
||||
address.setValue(movedView.getViewModel());
|
||||
|
||||
assertSame(moved, person.getAddress());
|
||||
assertEquals(1, personModel.getMessages().size());
|
||||
Message carried = personModel.getMessages().get(0);
|
||||
assertEquals("address.street", carried.getSource());
|
||||
assertEquals("under repair", carried.getText());
|
||||
assertEquals(Message.Severity.WARN, carried.getSeverity());
|
||||
assertSame(carried, personModel.getLatestMessage());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void fixingTheStreetLeavesThePersonsOwnNotice() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
URL fxml = CarryChildMessageTest.class.getResource("address-street-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
ViewModel personModel = loaded.getViewModel();
|
||||
personModel.addMessage(new Message(Message.Severity.INFO, "keep me", "form"));
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
streetField.setText("nope");
|
||||
|
||||
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||
assertEquals(1, addressModel.getMessages().size());
|
||||
assertEquals(2, personModel.getMessages().size());
|
||||
Message carried = personModel.getMessages().get(1);
|
||||
assertEquals("address.street", carried.getSource());
|
||||
assertEquals("not a street: nope", carried.getText());
|
||||
assertSame(carried, personModel.getLatestMessage());
|
||||
|
||||
streetField.setText("still-bad");
|
||||
|
||||
assertEquals(2, personModel.getMessages().size());
|
||||
assertSame(carried, personModel.getMessages().get(1));
|
||||
assertEquals("not a street: still-bad", carried.getText());
|
||||
assertSame(carried, personModel.getLatestMessage());
|
||||
assertEquals("keep me", personModel.getMessages().get(0).getText());
|
||||
|
||||
streetField.setText("ok Oak");
|
||||
|
||||
assertEquals("Oak", person.getAddress().getStreet());
|
||||
assertEquals(0, addressModel.getMessages().size());
|
||||
assertEquals(1, personModel.getMessages().size());
|
||||
assertEquals("keep me", personModel.getLatestMessage().getText());
|
||||
assertEquals("form", personModel.getLatestMessage().getSource());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFixedNestedStreetDropsTheNoticeUpThePath() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
Household household = new Household();
|
||||
household.setPerson(person);
|
||||
URL fxml = CarryChildMessageTest.class.getResource("household-street-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, household, context);
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
streetField.setText("nope");
|
||||
streetField.setText("ok Oak");
|
||||
|
||||
assertEquals("Oak", person.getAddress().getStreet());
|
||||
ViewModel householdModel = loaded.getViewModel();
|
||||
ViewModel personModel = (ViewModel) householdModel.property("person").getValue();
|
||||
ViewModel addressModel = (ViewModel) personModel.property("address").getValue();
|
||||
assertEquals(0, addressModel.getMessages().size());
|
||||
assertEquals(0, personModel.getMessages().size());
|
||||
assertEquals(0, householdModel.getMessages().size());
|
||||
assertNull(addressModel.getLatestMessage());
|
||||
assertNull(personModel.getLatestMessage());
|
||||
assertNull(householdModel.getLatestMessage());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSharedAddressCarriesTheStreetNoticeOnHomeAndOnWork() throws Exception {
|
||||
onFx(() -> {
|
||||
Address shared = new Address("Main");
|
||||
Person person = new Person("Ada");
|
||||
person.setHome(shared);
|
||||
person.setWork(shared);
|
||||
URL fxml = CarryChildMessageTest.class.getResource("plain-view.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
ViewModel personModel = loaded.getViewModel();
|
||||
ViewModel homeModel = (ViewModel) personModel.property("home").getValue();
|
||||
homeModel.addMessage(new Message(Message.Severity.INFO, "lane is closed", "street"));
|
||||
|
||||
assertEquals(2, personModel.getMessages().size());
|
||||
Set<String> sources = new HashSet<>();
|
||||
for (Message message : personModel.getMessages()) {
|
||||
sources.add(message.getSource());
|
||||
assertEquals("lane is closed", message.getText());
|
||||
}
|
||||
assertEquals(Set.of("home.street", "work.street"), sources);
|
||||
});
|
||||
}
|
||||
|
||||
private static void onFx(FxAction action) throws Exception {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable failure) {
|
||||
thrown.set(failure);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX thread did not finish");
|
||||
}
|
||||
Throwable failure = thrown.get();
|
||||
if (failure instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface FxAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.fixture.Address;
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
class ChooseControllerAndViewModelTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelShowsTheNameRecordedWhenTheViewModelWasHandedOver() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ChooseControllerAndViewModelTest.class.getResource("handed-name.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Label handedLabel = (Label) loaded.getRoot().lookup("#handedLabel");
|
||||
assertEquals("handed-Ada", handedLabel.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelShowsTheGreetingInjectedIntoTheController() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ChooseControllerAndViewModelTest.class.getResource("desk-greeting.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Label deskLabel = (Label) loaded.getRoot().lookup("#deskLabel");
|
||||
assertEquals("from-the-desk", deskLabel.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelShowsTheTitleFromTheNamedViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ChooseControllerAndViewModelTest.class.getResource("titled-name.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Label titleLabel = (Label) loaded.getRoot().lookup("#titleLabel");
|
||||
assertEquals("titled-Ada", titleLabel.getText());
|
||||
@SuppressWarnings("unchecked")
|
||||
Property<String> title = (Property<String>) loaded.property("title");
|
||||
assertNotNull(title);
|
||||
assertEquals("titled-Ada", title.getValue());
|
||||
title.setValue("titled-Grace");
|
||||
assertEquals("titled-Grace", titleLabel.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void labelShowsTheBadgeFromAReplacementViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ChooseControllerAndViewModelTest.class.getResource("badge-name.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Label badgeLabel = (Label) loaded.getRoot().lookup("#badgeLabel");
|
||||
assertEquals("badge-Ada", badgeLabel.getText());
|
||||
@SuppressWarnings("unchecked")
|
||||
Property<String> badge = (Property<String>) loaded.property("badge");
|
||||
assertNotNull(badge);
|
||||
assertEquals("badge-Ada", badge.getValue());
|
||||
badge.setValue("badge-Grace");
|
||||
assertEquals("badge-Grace", badgeLabel.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void componentLabelShowsTheCaptionFromTheNamedViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
URL fxml = ChooseControllerAndViewModelTest.class.getResource("caption-address.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Label captionLabel = (Label) loaded.getRoot().lookup("#captionLabel");
|
||||
assertEquals("caption-Main", captionLabel.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLaterViewThatNamesADifferentClassFailsAndLeavesTheFirstView() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL titled = ChooseControllerAndViewModelTest.class.getResource("titled-name.fxml");
|
||||
URL badge = ChooseControllerAndViewModelTest.class.getResource("badge-name.fxml");
|
||||
assertNotNull(titled);
|
||||
assertNotNull(badge);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
|
||||
LoadedView first = ViewLoader.load(titled, person, context);
|
||||
Label titleLabel = (Label) first.getRoot().lookup("#titleLabel");
|
||||
assertEquals("titled-Ada", titleLabel.getText());
|
||||
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
|
||||
() -> ViewLoader.load(badge, person, context));
|
||||
assertTrue(failure.getMessage().contains("TitledViewModel"), failure.getMessage());
|
||||
assertTrue(failure.getMessage().contains("BadgeViewModel"), failure.getMessage());
|
||||
assertEquals("titled-Ada", titleLabel.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLaterViewWithNoAnnotationReusesTheViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL named = ChooseControllerAndViewModelTest.class.getResource("named-person.fxml");
|
||||
URL plain = ChooseControllerAndViewModelTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(named);
|
||||
assertNotNull(plain);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.chosen");
|
||||
LoadedView first = ViewLoader.load(named, person, context);
|
||||
LoadedView second = ViewLoader.load(plain, person, context);
|
||||
TextField firstName = (TextField) first.getRoot().lookup("#nameField");
|
||||
TextField secondName = (TextField) second.getRoot().lookup("#nameField");
|
||||
assertEquals("Ada", firstName.getText());
|
||||
assertEquals("Ada", secondName.getText());
|
||||
firstName.setText("Grace");
|
||||
assertEquals("Grace", secondName.getText());
|
||||
assertEquals("Grace", person.getName());
|
||||
});
|
||||
}
|
||||
|
||||
private static void onFx(FxAction action) throws Exception {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable failure) {
|
||||
thrown.set(failure);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX thread did not finish");
|
||||
}
|
||||
Throwable failure = thrown.get();
|
||||
if (failure instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface FxAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
class ComputeAValueTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void computedBindingShowsTheMethodResultAndLeavesTheModelUnchanged() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(36);
|
||||
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
|
||||
assertEquals("initial-age", workingAgeLabel.getText());
|
||||
assertEquals("Ada", person.getName());
|
||||
assertEquals(36, person.getAge());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void typingInAnotherTextFieldUpdatesTheComputedLabel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
|
||||
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
|
||||
nameField.setText("Grace");
|
||||
assertEquals("typed-age", workingAgeLabel.getText());
|
||||
assertEquals("Grace", person.getName());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingAViewModelPropertyUpdatesTheComputedLabel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
|
||||
@SuppressWarnings("unchecked")
|
||||
Property<String> name = (Property<String>) loaded.property("name");
|
||||
name.setValue("Edsger");
|
||||
assertEquals("model-age", workingAgeLabel.getText());
|
||||
assertEquals("Edsger", person.getName());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void changingTheComputedLabelDoesNotRunTheMethodAgain() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ComputeAValueTest.class.getResource("working-age.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.computed");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Label workingAgeLabel = (Label) loaded.getRoot().lookup("#workingAgeLabel");
|
||||
assertEquals("initial-age", workingAgeLabel.getText());
|
||||
workingAgeLabel.setText("sentinel");
|
||||
assertEquals("sentinel", workingAgeLabel.getText());
|
||||
});
|
||||
}
|
||||
|
||||
private static void onFx(FxAction action) throws Exception {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable failure) {
|
||||
thrown.set(failure);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX thread did not finish");
|
||||
}
|
||||
Throwable failure = thrown.get();
|
||||
if (failure instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface FxAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
class ConvertAndRecordFailureTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void injectedConverterPrefixesTheAgeText() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(42);
|
||||
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||
assertEquals("years:42", ageField.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void typingAPrefixedAgeUpdatesTheModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(42);
|
||||
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||
ageField.setText("years:11");
|
||||
assertEquals(11, person.getAge());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void towardViewLoadsWhenTheReverseMethodIsAbsent() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(42);
|
||||
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-view-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Label ageLabel = (Label) loaded.getRoot().lookup("#ageLabel");
|
||||
assertEquals("shown 42", ageLabel.getText());
|
||||
ageLabel.setText("shown 99");
|
||||
assertEquals(42, person.getAge());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void towardViewConverterFollowsALaterAge() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(42);
|
||||
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-and-view-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||
Label ageLabel = (Label) loaded.getRoot().lookup("#ageLabel");
|
||||
assertEquals("shown 42", ageLabel.getText());
|
||||
ageField.setText("years:11");
|
||||
assertEquals(11, person.getAge());
|
||||
assertEquals("shown 11", ageLabel.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void towardModelConvertsTypedTextWithoutFollowingTheAttribute() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(42);
|
||||
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-model-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||
assertEquals("years:42", ageField.getText());
|
||||
((IntegerProperty) loaded.property("age")).set(7);
|
||||
assertEquals("years:42", ageField.getText());
|
||||
assertEquals(Integer.valueOf(7), person.getAge());
|
||||
ageField.setText("years:8");
|
||||
assertEquals(Integer.valueOf(8), person.getAge());
|
||||
assertEquals("years:8", ageField.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void badAgeTextKeepsThePreviousIntAndOneErrorMessage() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(42);
|
||||
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||
ageField.setText("nope");
|
||||
assertEquals(Integer.valueOf(42), person.getAge());
|
||||
assertEquals("nope", ageField.getText());
|
||||
ViewModel viewModel = loaded.getViewModel();
|
||||
assertEquals(1, viewModel.getMessages().size());
|
||||
Message message = viewModel.getMessages().get(0);
|
||||
assertEquals(Message.Severity.ERROR, message.getSeverity());
|
||||
assertEquals("age", message.getSource());
|
||||
assertEquals("not a whole number: nope", message.getText());
|
||||
assertSame(message, viewModel.getLatestMessage());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void anotherBadAgeUpdatesTheSameMessage() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(42);
|
||||
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||
ageField.setText("nope");
|
||||
ViewModel viewModel = loaded.getViewModel();
|
||||
Message first = viewModel.getMessages().get(0);
|
||||
ageField.setText("still-bad");
|
||||
assertEquals(1, viewModel.getMessages().size());
|
||||
assertSame(first, viewModel.getMessages().get(0));
|
||||
assertEquals("not a whole number: still-bad", first.getText());
|
||||
assertEquals(Integer.valueOf(42), person.getAge());
|
||||
assertEquals("still-bad", ageField.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValidAgeRemovesTheFailureMessage() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(42);
|
||||
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||
ageField.setText("nope");
|
||||
ageField.setText("years:11");
|
||||
assertEquals(Integer.valueOf(11), person.getAge());
|
||||
assertEquals("years:11", ageField.getText());
|
||||
assertEquals(0, loaded.getViewModel().getMessages().size());
|
||||
assertNull(loaded.getViewModel().getLatestMessage());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void developerInfoStaysLatestAndIsNotLogged() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(42);
|
||||
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||
ViewModel viewModel = loaded.getViewModel();
|
||||
ageField.setText("nope");
|
||||
String log = captureErr(() -> viewModel.addMessage(
|
||||
new Message(Message.Severity.INFO, "developer-info-notice", "form")));
|
||||
assertEquals(2, viewModel.getMessages().size());
|
||||
assertEquals("developer-info-notice", viewModel.getLatestMessage().getText());
|
||||
assertEquals(Message.Severity.INFO, viewModel.getLatestMessage().getSeverity());
|
||||
assertFalse(log.contains("developer-info-notice"));
|
||||
ageField.setText("still-bad");
|
||||
assertEquals(2, viewModel.getMessages().size());
|
||||
assertEquals("not a whole number: still-bad", viewModel.getMessages().get(0).getText());
|
||||
assertEquals("developer-info-notice", viewModel.getLatestMessage().getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorAndFatalMessagesAreWrittenToTheLog() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(42);
|
||||
URL fxml = ConvertAndRecordFailureTest.class.getResource("age-both-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||
ViewModel viewModel = loaded.getViewModel();
|
||||
String log = captureErr(() -> {
|
||||
viewModel.addMessage(new Message(Message.Severity.WARN, "developer-warn-notice", "form"));
|
||||
viewModel.addMessage(new Message(Message.Severity.ERROR, "developer-error-notice", "form"));
|
||||
viewModel.addMessage(new Message(Message.Severity.FATAL, "developer-fatal-notice", "form"));
|
||||
ageField.setText("nope");
|
||||
ageField.setText("still-bad");
|
||||
});
|
||||
assertFalse(log.contains("developer-warn-notice"));
|
||||
assertTrue(log.contains("developer-error-notice"));
|
||||
assertTrue(log.contains("developer-fatal-notice"));
|
||||
assertTrue(log.contains("not a whole number: nope"));
|
||||
assertTrue(log.contains("not a whole number: still-bad"));
|
||||
assertEquals("not a whole number: still-bad", viewModel.getLatestMessage().getText());
|
||||
assertEquals(4, viewModel.getMessages().size());
|
||||
});
|
||||
}
|
||||
|
||||
private static void onFx(FxAction action) throws Exception {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable failure) {
|
||||
thrown.set(failure);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX thread did not finish");
|
||||
}
|
||||
Throwable failure = thrown.get();
|
||||
if (failure instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String captureErr(FxAction action) throws Exception {
|
||||
PrintStream previous = System.err;
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
PrintStream captured = new PrintStream(buffer, true, StandardCharsets.UTF_8);
|
||||
System.setErr(captured);
|
||||
try {
|
||||
action.run();
|
||||
captured.flush();
|
||||
return buffer.toString(StandardCharsets.UTF_8);
|
||||
} finally {
|
||||
System.setErr(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface FxAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.component.AddressForm;
|
||||
import de.neitzel.fx.view.component.Envelope;
|
||||
import de.neitzel.fx.view.component.Letter;
|
||||
import de.neitzel.fx.view.fixture.Address;
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
class EditAddressInAComponentTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void editingTheStreetUpdatesThatAddress() throws Exception {
|
||||
onFx(() -> {
|
||||
Address address = new Address("Main");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(address);
|
||||
URL fxml = EditAddressInAComponentTest.class.getResource("address-component.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
AddressForm form = (AddressForm) loaded.getRoot().lookup("#addressForm");
|
||||
assertNotNull(form);
|
||||
assertSame(address, form.getModel());
|
||||
TextField streetField = (TextField) form.lookup("#streetField");
|
||||
assertEquals("Main", streetField.getText());
|
||||
streetField.setText("Oak");
|
||||
assertEquals("Oak", address.getStreet());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullAddressLeavesTheModelUnsetAndKeepsTheStreetText() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = EditAddressInAComponentTest.class.getResource("address-component.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
AddressForm form = (AddressForm) loaded.getRoot().lookup("#addressForm");
|
||||
assertNotNull(form);
|
||||
assertNull(form.getModel());
|
||||
TextField streetField = (TextField) form.lookup("#streetField");
|
||||
assertEquals("kept", streetField.getText());
|
||||
assertNull(person.getAddress());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void streetLabelShowsTheInjectedPrefix() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
URL fxml = EditAddressInAComponentTest.class.getResource("address-component.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
AddressForm form = (AddressForm) loaded.getRoot().lookup("#addressForm");
|
||||
Label streetLabel = (Label) form.lookup("#streetLabel");
|
||||
assertEquals("Lane", streetLabel.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void componentReceivesTheAddressViewModelBeforeItsBindingsRun() throws Exception {
|
||||
onFx(() -> {
|
||||
Address address = new Address("Main");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(address);
|
||||
URL fxml = EditAddressInAComponentTest.class.getResource("address-component.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
AddressForm form = (AddressForm) loaded.getRoot().lookup("#addressForm");
|
||||
assertSame(loaded.property("address").getValue(), form.receivedViewModel());
|
||||
assertEquals("kept", form.streetTextAtHandOff());
|
||||
TextField streetField = (TextField) form.lookup("#streetField");
|
||||
assertEquals("Main", streetField.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void nestedComponentShowsTheInnerStreet() throws Exception {
|
||||
onFx(() -> {
|
||||
Address address = new Address("Main");
|
||||
Letter letter = new Letter(new Envelope(address));
|
||||
URL fxml = EditAddressInAComponentTest.class.getResource("letter-envelope.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
LoadedView loaded = ViewLoader.load(fxml, letter, context);
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
assertEquals("Main", streetField.getText());
|
||||
streetField.setText("Oak");
|
||||
assertEquals("Oak", address.getStreet());
|
||||
});
|
||||
}
|
||||
|
||||
private static void onFx(FxAction action) throws Exception {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable failure) {
|
||||
thrown.set(failure);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX thread did not finish");
|
||||
}
|
||||
Throwable failure = thrown.get();
|
||||
if (failure instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface FxAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.fixture.Address;
|
||||
import de.neitzel.fx.view.fixture.ObjectHolder;
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.fx.view.fixture.Role;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URL;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
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.fail;
|
||||
|
||||
class ReachNestedAttributeTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void complexAttributeHoldsTheViewModelForTheNestedAddress() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(new Address("Main"));
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Object addressValue = loaded.property("address").getValue();
|
||||
assertInstanceOf(ViewModel.class, addressValue);
|
||||
assertEquals("Main", ((ViewModel) addressValue).property("street").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothWaysTextFieldReadsAndWritesTheNestedStreet() throws Exception {
|
||||
onFx(() -> {
|
||||
Address address = new Address("Main");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(address);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("address-street-both.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
assertEquals("Main", streetField.getText());
|
||||
streetField.setText("Oak");
|
||||
assertEquals("Oak", address.getStreet());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void towardViewLabelFollowsTheNestedStreetAndDoesNotWrite() throws Exception {
|
||||
onFx(() -> {
|
||||
Address address = new Address("Main");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(address);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("address-street-both-and-view.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
Label streetLabel = (Label) loaded.getRoot().lookup("#streetLabel");
|
||||
assertEquals("Main", streetLabel.getText());
|
||||
streetField.setText("Oak");
|
||||
assertEquals("Oak", streetLabel.getText());
|
||||
assertEquals("Oak", address.getStreet());
|
||||
streetLabel.setText("Side");
|
||||
assertEquals("Oak", address.getStreet());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothWaysBindingOnAComplexPathYieldsTheModelInstance() throws Exception {
|
||||
onFx(() -> {
|
||||
Address address = new Address("Main");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddress(address);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("address-object.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
ObjectHolder addressHolder = (ObjectHolder) loaded.getRoot().lookup("#addressHolder");
|
||||
assertSame(address, addressHolder.getValue());
|
||||
Address moved = new Address("Oak");
|
||||
addressHolder.setValue(moved);
|
||||
assertSame(moved, person.getAddress());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void fillingAMissingAddressStoresTheViewModelAndWritesTheModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("address-object.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
ObjectHolder addressHolder = (ObjectHolder) loaded.getRoot().lookup("#addressHolder");
|
||||
assertNull(addressHolder.getValue());
|
||||
assertNull(person.getAddress());
|
||||
Address address = new Address("Main");
|
||||
addressHolder.setValue(address);
|
||||
assertSame(address, person.getAddress());
|
||||
Object addressValue = loaded.property("address").getValue();
|
||||
assertInstanceOf(ViewModel.class, addressValue);
|
||||
assertEquals("Main", ((ViewModel) addressValue).property("street").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingAddressLeavesTheControlAndTheModelUnchanged() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("address-street-kept.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField streetField = (TextField) loaded.getRoot().lookup("#streetField");
|
||||
assertEquals("kept", streetField.getText());
|
||||
assertNull(person.getAddress());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullComplexAttributeHoldsNull() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertNull(loaded.property("address").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void attributesThatReachTheSameAddressShareItsViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Address shared = new Address("Main");
|
||||
Person person = new Person("Ada");
|
||||
person.setHome(shared);
|
||||
person.setWork(shared);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("home-work-street.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertSame(loaded.property("home").getValue(), loaded.property("work").getValue());
|
||||
TextField homeField = (TextField) loaded.getRoot().lookup("#homeField");
|
||||
TextField workField = (TextField) loaded.getRoot().lookup("#workField");
|
||||
homeField.setText("Oak");
|
||||
assertEquals("Oak", workField.getText());
|
||||
assertEquals("Oak", shared.getStreet());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void equalAddressesKeepSeparateViewModels() throws Exception {
|
||||
onFx(() -> {
|
||||
Address home = new Address("Main");
|
||||
Address work = new Address("Main");
|
||||
assertEquals(home, work);
|
||||
Person person = new Person("Ada");
|
||||
person.setHome(home);
|
||||
person.setWork(work);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("home-work-street.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertNotSame(loaded.property("home").getValue(), loaded.property("work").getValue());
|
||||
TextField homeField = (TextField) loaded.getRoot().lookup("#homeField");
|
||||
TextField workField = (TextField) loaded.getRoot().lookup("#workField");
|
||||
homeField.setText("Oak");
|
||||
assertEquals("Oak", home.getStreet());
|
||||
assertEquals("Main", work.getStreet());
|
||||
assertEquals("Main", workField.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPersonWhoIsTheirOwnFriendReusesTheSameViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setFriend(person);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertSame(loaded.getViewModel(), loaded.property("friend").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoPeopleWhoPointAtEachOtherReuseTheViewModelsAlreadyBuilt() throws Exception {
|
||||
onFx(() -> {
|
||||
Person ada = new Person("Ada");
|
||||
Person grace = new Person("Grace");
|
||||
ada.setFriend(grace);
|
||||
grace.setFriend(ada);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, ada, context);
|
||||
ViewModel graceModel = (ViewModel) loaded.property("friend").getValue();
|
||||
assertEquals("Grace", graceModel.property("name").getValue());
|
||||
assertSame(loaded.getViewModel(), graceModel.property("friend").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void enumsBooleansAndDateValuesStaySimpleValues() throws Exception {
|
||||
onFx(() -> {
|
||||
Date updated = new Date(0L);
|
||||
Calendar hired = new GregorianCalendar(2020, Calendar.JANUARY, 2);
|
||||
LocalDate born = LocalDate.of(1815, 12, 10);
|
||||
Person person = new Person("Ada");
|
||||
person.setActive(true);
|
||||
person.setRole(Role.ADMIN);
|
||||
person.setBorn(born);
|
||||
person.setUpdated(updated);
|
||||
person.setHired(hired);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertEquals(true, loaded.property("active").getValue());
|
||||
assertSame(Role.ADMIN, loaded.property("role").getValue());
|
||||
assertEquals(born, loaded.property("born").getValue());
|
||||
assertSame(updated, loaded.property("updated").getValue());
|
||||
assertSame(hired, loaded.property("hired").getValue());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aCollectionStaysTheCollectionRatherThanAViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
List<String> tags = new ArrayList<>();
|
||||
tags.add("friend");
|
||||
Person person = new Person("Ada");
|
||||
person.setTags(tags);
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertInstanceOf(ObservableList.class, loaded.property("tags").getValue());
|
||||
@SuppressWarnings("unchecked")
|
||||
ObservableList<String> shown = (ObservableList<String>) loaded.property("tags").getValue();
|
||||
assertEquals(List.of("friend"), shown);
|
||||
assertSame(person.getTags(), shown);
|
||||
shown.add("kin");
|
||||
assertEquals(List.of("friend", "kin"), tags);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoLoadsOfTheSamePersonShareTheViewModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ReachNestedAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView first = ViewLoader.load(fxml, person, context);
|
||||
LoadedView second = ViewLoader.load(fxml, person, context);
|
||||
assertSame(first.getViewModel(), second.getViewModel());
|
||||
TextField firstName = (TextField) first.getRoot().lookup("#nameField");
|
||||
TextField secondName = (TextField) second.getRoot().lookup("#nameField");
|
||||
firstName.setText("Grace");
|
||||
assertEquals("Grace", secondName.getText());
|
||||
assertEquals("Grace", person.getName());
|
||||
});
|
||||
}
|
||||
|
||||
private static void onFx(FxAction action) throws Exception {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable failure) {
|
||||
thrown.set(failure);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX thread did not finish");
|
||||
}
|
||||
Throwable failure = thrown.get();
|
||||
if (failure instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface FxAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.component.AddressForm;
|
||||
import de.neitzel.fx.view.fixture.Address;
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
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 java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
class RepeatAddressComponentsTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addressesAreTheAddressInstancesOnTheSharedList() throws Exception {
|
||||
onFx(() -> {
|
||||
Address main = new Address("Main");
|
||||
Address oak = new Address("Oak");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddresses(new ArrayList<>(List.of(main, oak)));
|
||||
URL fxml = RepeatAddressComponentsTest.class.getResource("plain-view.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertInstanceOf(ObservableList.class, loaded.property("addresses").getValue());
|
||||
@SuppressWarnings("unchecked")
|
||||
ObservableList<Address> addresses = (ObservableList<Address>) loaded.property("addresses").getValue();
|
||||
assertSame(main, addresses.get(0));
|
||||
assertSame(oak, addresses.get(1));
|
||||
assertEquals("Main", addresses.get(0).getStreet());
|
||||
assertEquals("Oak", addresses.get(1).getStreet());
|
||||
assertSame(person.getAddresses(), addresses);
|
||||
addresses.add(new Address("Elm"));
|
||||
assertEquals(List.of("Main", "Oak", "Elm"), streets(person.getAddresses()));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void eachAddressAppearsAsItsOwnFormInListOrder() throws Exception {
|
||||
onFx(() -> {
|
||||
Address main = new Address("Main");
|
||||
Address oak = new Address("Oak");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddresses(new ArrayList<>(List.of(main, oak)));
|
||||
URL fxml = RepeatAddressComponentsTest.class.getResource("repeat-addresses.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
AddressForm cached = context.getComponent(AddressForm.class);
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
VBox pane = (VBox) loaded.getRoot().lookup("#addressPane");
|
||||
assertEquals(2, pane.getChildren().size());
|
||||
AddressForm first = (AddressForm) pane.getChildren().get(0);
|
||||
AddressForm second = (AddressForm) pane.getChildren().get(1);
|
||||
assertNotSame(first, second);
|
||||
assertNotSame(cached, first);
|
||||
assertNotSame(cached, second);
|
||||
assertSame(cached, context.getComponent(AddressForm.class));
|
||||
assertSame(main, first.getModel());
|
||||
assertSame(oak, second.getModel());
|
||||
assertEquals("Main", ((TextField) first.lookup("#streetField")).getText());
|
||||
assertEquals("Oak", ((TextField) second.lookup("#streetField")).getText());
|
||||
assertEquals("Lane", ((Label) first.lookup("#streetLabel")).getText());
|
||||
assertEquals("Lane", ((Label) second.lookup("#streetLabel")).getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void addingAndRemovingAnAddressUpdatesThePaneAndTheEntity() throws Exception {
|
||||
onFx(() -> {
|
||||
Address main = new Address("Main");
|
||||
Address oak = new Address("Oak");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddresses(new ArrayList<>(List.of(main, oak)));
|
||||
URL fxml = RepeatAddressComponentsTest.class.getResource("repeat-addresses.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
VBox pane = (VBox) loaded.getRoot().lookup("#addressPane");
|
||||
@SuppressWarnings("unchecked")
|
||||
ObservableList<Address> addresses = (ObservableList<Address>) loaded.property("addresses").getValue();
|
||||
Address elm = new Address("Elm");
|
||||
addresses.add(elm);
|
||||
assertEquals(3, pane.getChildren().size());
|
||||
AddressForm added = (AddressForm) pane.getChildren().get(2);
|
||||
assertSame(elm, added.getModel());
|
||||
assertEquals("Elm", ((TextField) added.lookup("#streetField")).getText());
|
||||
assertEquals(List.of("Main", "Oak", "Elm"), streets(person.getAddresses()));
|
||||
addresses.remove(oak);
|
||||
assertEquals(2, pane.getChildren().size());
|
||||
assertSame(main, ((AddressForm) pane.getChildren().get(0)).getModel());
|
||||
assertSame(elm, ((AddressForm) pane.getChildren().get(1)).getModel());
|
||||
assertEquals(List.of("Main", "Elm"), streets(person.getAddresses()));
|
||||
Address pine = new Address("Pine");
|
||||
person.getAddresses().add(pine);
|
||||
assertEquals(3, pane.getChildren().size());
|
||||
AddressForm pineForm = (AddressForm) pane.getChildren().get(2);
|
||||
assertSame(pine, pineForm.getModel());
|
||||
assertEquals("Pine", ((TextField) pineForm.lookup("#streetField")).getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectionIsASeparateAttributeAndTheFormsDoNotStoreIt() throws Exception {
|
||||
onFx(() -> {
|
||||
Address main = new Address("Main");
|
||||
Address oak = new Address("Oak");
|
||||
Person person = new Person("Ada");
|
||||
person.setAddresses(new ArrayList<>(List.of(main, oak)));
|
||||
person.setSelectedAddress(oak);
|
||||
URL fxml = RepeatAddressComponentsTest.class.getResource("repeat-addresses.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
@SuppressWarnings("unchecked")
|
||||
ComboBox<Address> selection = (ComboBox<Address>) loaded.getRoot().lookup("#selection");
|
||||
assertSame(oak, selection.getValue());
|
||||
selection.setValue(null);
|
||||
assertNull(person.getSelectedAddress());
|
||||
selection.setValue(main);
|
||||
assertSame(main, person.getSelectedAddress());
|
||||
VBox pane = (VBox) loaded.getRoot().lookup("#addressPane");
|
||||
assertEquals(2, pane.getChildren().size());
|
||||
AddressForm first = (AddressForm) pane.getChildren().get(0);
|
||||
AddressForm second = (AddressForm) pane.getChildren().get(1);
|
||||
assertSame(main, first.getModel());
|
||||
assertSame(oak, second.getModel());
|
||||
assertEquals("Main", ((TextField) first.lookup("#streetField")).getText());
|
||||
assertEquals("Oak", ((TextField) second.lookup("#streetField")).getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSecondAddressFormInTheSameDocumentIsAnotherComponent() throws Exception {
|
||||
onFx(() -> {
|
||||
Address home = new Address("Main");
|
||||
Address work = new Address("Oak");
|
||||
Person person = new Person("Ada");
|
||||
person.setHome(home);
|
||||
person.setWork(work);
|
||||
URL fxml = RepeatAddressComponentsTest.class.getResource("two-address-forms.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.component");
|
||||
AddressForm cached = context.getComponent(AddressForm.class);
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
AddressForm homeForm = (AddressForm) loaded.getRoot().lookup("#homeForm");
|
||||
AddressForm workForm = (AddressForm) loaded.getRoot().lookup("#workForm");
|
||||
assertNotNull(homeForm);
|
||||
assertNotNull(workForm);
|
||||
assertNotSame(homeForm, workForm);
|
||||
assertSame(cached, context.getComponent(AddressForm.class));
|
||||
assertSame(home, homeForm.getModel());
|
||||
assertSame(work, workForm.getModel());
|
||||
assertEquals("Main", ((TextField) homeForm.lookup("#streetField")).getText());
|
||||
assertEquals("Oak", ((TextField) workForm.lookup("#streetField")).getText());
|
||||
assertEquals("Lane", ((Label) homeForm.lookup("#streetLabel")).getText());
|
||||
assertEquals("Lane", ((Label) workForm.lookup("#streetLabel")).getText());
|
||||
});
|
||||
}
|
||||
|
||||
private static List<String> streets(List<Address> addresses) {
|
||||
return addresses.stream().map(Address::getStreet).toList();
|
||||
}
|
||||
|
||||
private static void onFx(FxAction action) throws Exception {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable failure) {
|
||||
thrown.set(failure);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX thread did not finish");
|
||||
}
|
||||
Throwable failure = thrown.get();
|
||||
if (failure instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface FxAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.ListView;
|
||||
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 java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
class ShowAListOfSimpleValuesTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void listViewAndPersonShareTheTagList() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setTags(new ArrayList<>(List.of("red", "blue")));
|
||||
URL fxml = ShowAListOfSimpleValuesTest.class.getResource("tags-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
VBox root = (VBox) loaded.getRoot();
|
||||
assertEquals(1, root.getChildren().size());
|
||||
@SuppressWarnings("unchecked")
|
||||
ListView<String> tags = (ListView<String>) root.lookup("#tags");
|
||||
assertEquals(List.of("red", "blue"), List.copyOf(tags.getItems()));
|
||||
assertEquals("red", tags.getItems().get(0));
|
||||
assertEquals("blue", tags.getItems().get(1));
|
||||
assertSame(person.getTags(), tags.getItems());
|
||||
tags.getItems().add("green");
|
||||
assertEquals(List.of("red", "blue", "green"), List.copyOf(person.getTags()));
|
||||
person.getTags().add("gold");
|
||||
assertEquals(List.of("red", "blue", "green", "gold"), List.copyOf(tags.getItems()));
|
||||
tags.getItems().remove("blue");
|
||||
assertEquals(List.of("red", "green", "gold"), List.copyOf(person.getTags()));
|
||||
});
|
||||
}
|
||||
|
||||
private static void onFx(FxAction action) throws Exception {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable failure) {
|
||||
thrown.set(failure);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX thread did not finish");
|
||||
}
|
||||
Throwable failure = thrown.get();
|
||||
if (failure instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface FxAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
class ShowAndEditOneAttributeTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothWaysTextFieldShowsTheNameAndTypingUpdatesTheModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
|
||||
assertEquals("Ada", nameField.getText());
|
||||
nameField.setText("Grace");
|
||||
assertEquals("Grace", person.getName());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void towardViewLabelShowsTheNameFollowsTheFieldAndDoesNotWriteTheModel() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-and-view.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
|
||||
Label nameLabel = (Label) loaded.getRoot().lookup("#nameLabel");
|
||||
assertEquals("Ada", nameLabel.getText());
|
||||
nameField.setText("Grace");
|
||||
assertEquals("Grace", nameLabel.getText());
|
||||
assertEquals("Grace", person.getName());
|
||||
nameLabel.setText("Hopper");
|
||||
assertEquals("Grace", person.getName());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void entitySetterAfterLoadLeavesTheTextControlUnchanged() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
person.setName("Grace");
|
||||
TextField nameField = (TextField) loaded.getRoot().lookup("#nameField");
|
||||
assertEquals("Ada", nameField.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindingAReadOnlyGetterFailsTheLoad() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ShowAndEditOneAttributeTest.class.getResource("read-only-title.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
|
||||
() -> ViewLoader.load(fxml, person, context));
|
||||
assertTrue(failure.getMessage().contains("title"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindingAMethodDeclaredOnObjectFailsTheLoad() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ShowAndEditOneAttributeTest.class.getResource("object-class.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
|
||||
() -> ViewLoader.load(fxml, person, context));
|
||||
assertTrue(failure.getMessage().contains("class"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullIntegerAttributeUsesAnIntegerProperty() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ShowAndEditOneAttributeTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
assertInstanceOf(IntegerProperty.class, loaded.property("age"));
|
||||
});
|
||||
}
|
||||
|
||||
private static void onFx(FxAction action) throws Exception {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable failure) {
|
||||
thrown.set(failure);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX thread did not finish");
|
||||
}
|
||||
Throwable failure = thrown.get();
|
||||
if (failure instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface FxAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package de.neitzel.fx.view;
|
||||
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import de.neitzel.injection.ApplicationContext;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.control.DialogPane;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.Labeled;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.TextInputControl;
|
||||
import javafx.stage.Window;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
class ShowMessagesWhenAskedTest {
|
||||
|
||||
@BeforeAll
|
||||
static void startJavaFx() throws InterruptedException {
|
||||
Platform.setImplicitExit(false);
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
try {
|
||||
Platform.startup(started::countDown);
|
||||
} catch (IllegalStateException alreadyStarted) {
|
||||
started.countDown();
|
||||
}
|
||||
if (!started.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX toolkit did not start");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void addingAMessageDoesNotOpenAPopupOrFillAStatusBar() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ShowMessagesWhenAskedTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Parent root = loaded.getRoot();
|
||||
Set<Node> nodesBefore = new HashSet<>(root.lookupAll("*"));
|
||||
Set<Window> windowsBefore = snapshotWindows();
|
||||
loaded.getViewModel().addMessage(
|
||||
new Message(Message.Severity.INFO, "saved the person", "form"));
|
||||
assertEquals(windowsBefore, snapshotWindows());
|
||||
assertEquals(nodesBefore, new HashSet<>(root.lookupAll("*")));
|
||||
assertMessageIsNotShown(root, "saved the person");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void directBindingShowsTheLatestMessageText() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ShowMessagesWhenAskedTest.class.getResource("latest-message.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Label messageLabel = (Label) loaded.getRoot().lookup("#messageLabel");
|
||||
loaded.getViewModel().addMessage(
|
||||
new Message(Message.Severity.INFO, "saved the person", "form"));
|
||||
assertEquals("saved the person", messageLabel.getText());
|
||||
loaded.getViewModel().addMessage(
|
||||
new Message(Message.Severity.WARN, "name is required", "name"));
|
||||
assertEquals("name is required", messageLabel.getText());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void optedInHelperShowsANewMessageInAPopup() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
URL fxml = ShowMessagesWhenAskedTest.class.getResource("name-both-ways.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
Set<Window> windowsBefore = snapshotWindows();
|
||||
MessagePopup.showNewMessages(loaded.getViewModel());
|
||||
assertEquals(windowsBefore, snapshotWindows());
|
||||
try {
|
||||
loaded.getViewModel().addMessage(
|
||||
new Message(Message.Severity.INFO, "saved the person", "form"));
|
||||
assertNotNull(windowShowing("saved the person", windowsBefore));
|
||||
assertMessageIsNotShown(loaded.getRoot(), "saved the person");
|
||||
} finally {
|
||||
closeWindowsOpenedSince(windowsBefore);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatingABindingFailureDoesNotOpenAnotherPopup() throws Exception {
|
||||
onFx(() -> {
|
||||
Person person = new Person("Ada");
|
||||
person.setAge(42);
|
||||
URL fxml = ShowMessagesWhenAskedTest.class.getResource("age-both-converted.fxml");
|
||||
assertNotNull(fxml);
|
||||
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
|
||||
LoadedView loaded = ViewLoader.load(fxml, person, context);
|
||||
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
|
||||
Set<Window> windowsBefore = snapshotWindows();
|
||||
MessagePopup.showNewMessages(loaded.getViewModel());
|
||||
try {
|
||||
ageField.setText("nope");
|
||||
ViewModel viewModel = loaded.getViewModel();
|
||||
Message failure = viewModel.getLatestMessage();
|
||||
assertEquals("not a whole number: nope", failure.getText());
|
||||
assertEquals(1, windowsOpenedSince(windowsBefore).size());
|
||||
assertNotNull(windowShowing("not a whole number: nope", windowsBefore));
|
||||
|
||||
ageField.setText("still-bad");
|
||||
assertEquals(1, viewModel.getMessages().size());
|
||||
assertSame(failure, viewModel.getLatestMessage());
|
||||
assertEquals("not a whole number: still-bad", failure.getText());
|
||||
assertEquals(1, windowsOpenedSince(windowsBefore).size());
|
||||
|
||||
viewModel.addMessage(new Message(Message.Severity.INFO, "saved the person", "form"));
|
||||
assertEquals(2, windowsOpenedSince(windowsBefore).size());
|
||||
assertNotNull(windowShowing("saved the person", windowsBefore));
|
||||
assertEquals("saved the person", viewModel.getLatestMessage().getText());
|
||||
} finally {
|
||||
closeWindowsOpenedSince(windowsBefore);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Set<Window> snapshotWindows() {
|
||||
return new HashSet<>(Window.getWindows());
|
||||
}
|
||||
|
||||
private static Window windowShowing(String text, Set<Window> alreadyOpen) {
|
||||
for (Window window : Window.getWindows()) {
|
||||
if (!alreadyOpen.contains(window) && windowShows(window, text)) {
|
||||
return window;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean windowShows(Window window, String text) {
|
||||
if (window.getScene() == null || !(window.getScene().getRoot() instanceof Parent root)) {
|
||||
return false;
|
||||
}
|
||||
if (root instanceof DialogPane pane && nodeShows(pane.getContent(), text)) {
|
||||
return true;
|
||||
}
|
||||
for (Node node : root.lookupAll("*")) {
|
||||
if (nodeShows(node, text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean nodeShows(Node node, String text) {
|
||||
return node instanceof Labeled labeled && text.equals(labeled.getText());
|
||||
}
|
||||
|
||||
private static List<Window> windowsOpenedSince(Set<Window> alreadyOpen) {
|
||||
List<Window> opened = new ArrayList<>();
|
||||
for (Window window : Window.getWindows()) {
|
||||
if (!alreadyOpen.contains(window)) {
|
||||
opened.add(window);
|
||||
}
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
||||
private static void closeWindowsOpenedSince(Set<Window> alreadyOpen) {
|
||||
List<Window> open = new ArrayList<>(Window.getWindows());
|
||||
for (Window window : open) {
|
||||
if (!alreadyOpen.contains(window)) {
|
||||
window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertMessageIsNotShown(Parent root, String text) {
|
||||
for (Node node : root.lookupAll("*")) {
|
||||
if (node instanceof Labeled labeled) {
|
||||
assertNotEquals(text, labeled.getText());
|
||||
}
|
||||
if (node instanceof TextInputControl input) {
|
||||
assertNotEquals(text, input.getText());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void onFx(FxAction action) throws Exception {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
action.run();
|
||||
return;
|
||||
}
|
||||
AtomicReference<Throwable> thrown = new AtomicReference<>();
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
Platform.runLater(() -> {
|
||||
try {
|
||||
action.run();
|
||||
} catch (Throwable failure) {
|
||||
thrown.set(failure);
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
if (!done.await(10, TimeUnit.SECONDS)) {
|
||||
fail("JavaFX thread did not finish");
|
||||
}
|
||||
Throwable failure = thrown.get();
|
||||
if (failure instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface FxAction {
|
||||
void run() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.neitzel.fx.view.chosen;
|
||||
|
||||
import de.neitzel.fx.view.ViewModelClass;
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* Controller that names {@link BadgeViewModel} as a replacement ViewModel.
|
||||
*/
|
||||
@Singleton
|
||||
@ViewModelClass(BadgeViewModel.class)
|
||||
public class BadgeController {
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.neitzel.fx.view.chosen;
|
||||
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
|
||||
/**
|
||||
* Replacement for the library ViewModel. It exposes a badge property by name and does not
|
||||
* extend {@link de.neitzel.fx.view.ViewModel}.
|
||||
*/
|
||||
public class BadgeViewModel {
|
||||
|
||||
private final StringProperty badge = new SimpleStringProperty();
|
||||
|
||||
/**
|
||||
* Builds the replacement and sets the badge from the person's name.
|
||||
*
|
||||
* @param model the Model instance
|
||||
*/
|
||||
public BadgeViewModel(Object model) {
|
||||
Person person = (Person) model;
|
||||
badge.set("badge-" + person.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the badge property a Binding element addresses by name.
|
||||
*
|
||||
* @return the badge property
|
||||
*/
|
||||
public StringProperty badgeProperty() {
|
||||
return badge;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.neitzel.fx.view.chosen;
|
||||
|
||||
import de.neitzel.fx.view.ViewModelClass;
|
||||
import jakarta.inject.Singleton;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Component that names {@link StreetTitleViewModel} for the address it shows.
|
||||
*/
|
||||
@Singleton
|
||||
@ViewModelClass(StreetTitleViewModel.class)
|
||||
public class CaptionAddressForm extends VBox {
|
||||
|
||||
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
|
||||
|
||||
/**
|
||||
* Returns the model property a parent View sets with a Direct binding.
|
||||
*
|
||||
* @return the model property
|
||||
*/
|
||||
public ObjectProperty<Object> modelProperty() {
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the address this component shows.
|
||||
*
|
||||
* @return the address, or {@code null} when none is set
|
||||
*/
|
||||
public Object getModel() {
|
||||
return model.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the address this component shows.
|
||||
*
|
||||
* @param model the address
|
||||
*/
|
||||
public void setModel(Object model) {
|
||||
this.model.set(model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.neitzel.fx.view.chosen;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* Controller that shows a greeting supplied by an injected service.
|
||||
*/
|
||||
@Singleton
|
||||
public class DeskController {
|
||||
|
||||
private final DeskGreeting greeting;
|
||||
|
||||
/**
|
||||
* Creates a controller that reads its label from the injected greeting.
|
||||
*
|
||||
* @param greeting the service that supplies the greeting
|
||||
*/
|
||||
@Inject
|
||||
public DeskController(DeskGreeting greeting) {
|
||||
this.greeting = greeting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the greeting from the injected service.
|
||||
*
|
||||
* @return the label text
|
||||
*/
|
||||
public String deskGreeting() {
|
||||
return greeting.text();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.neitzel.fx.view.chosen;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* Service whose text a Controller must show, so a test can see that injection ran.
|
||||
*/
|
||||
@Singleton
|
||||
public class DeskGreeting {
|
||||
|
||||
/**
|
||||
* Returns the greeting written on the label.
|
||||
*
|
||||
* @return the greeting
|
||||
*/
|
||||
public String text() {
|
||||
return "from-the-desk";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.neitzel.fx.view.chosen;
|
||||
|
||||
import de.neitzel.fx.view.HasViewModel;
|
||||
import de.neitzel.fx.view.ViewModel;
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* Controller whose computed value is taken from the ViewModel handed over before bindings.
|
||||
*/
|
||||
@Singleton
|
||||
public class HandedNameController implements HasViewModel {
|
||||
|
||||
private String handed;
|
||||
|
||||
/**
|
||||
* Records a label value that is available only from the handed ViewModel.
|
||||
*
|
||||
* @param viewModel the ViewModel for this View
|
||||
*/
|
||||
@Override
|
||||
public void setViewModel(ViewModel viewModel) {
|
||||
handed = "handed-" + viewModel.property("name").getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value recorded when the ViewModel was handed over.
|
||||
*
|
||||
* @return the label text
|
||||
*/
|
||||
public String handedName() {
|
||||
return handed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.neitzel.fx.view.chosen;
|
||||
|
||||
import de.neitzel.fx.view.ViewModel;
|
||||
import de.neitzel.fx.view.fixture.Address;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
|
||||
/**
|
||||
* ViewModel for an address that adds a caption taken from the street.
|
||||
*/
|
||||
public class StreetTitleViewModel extends ViewModel {
|
||||
|
||||
private final StringProperty caption = new SimpleStringProperty();
|
||||
|
||||
/**
|
||||
* Builds a ViewModel and sets the caption from the address street.
|
||||
*
|
||||
* @param model the address
|
||||
*/
|
||||
public StreetTitleViewModel(Object model) {
|
||||
super(model);
|
||||
Address address = (Address) model;
|
||||
caption.set("caption-" + address.getStreet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the caption property a Binding element addresses by name.
|
||||
*
|
||||
* @return the caption property
|
||||
*/
|
||||
public StringProperty captionProperty() {
|
||||
return caption;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.neitzel.fx.view.chosen;
|
||||
|
||||
import de.neitzel.fx.view.ViewModelClass;
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* Controller that names {@link TitledViewModel} for its View.
|
||||
*/
|
||||
@Singleton
|
||||
@ViewModelClass(TitledViewModel.class)
|
||||
public class TitledController {
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.neitzel.fx.view.chosen;
|
||||
|
||||
import de.neitzel.fx.view.ViewModel;
|
||||
import de.neitzel.fx.view.fixture.Person;
|
||||
import javafx.beans.property.SimpleStringProperty;
|
||||
import javafx.beans.property.StringProperty;
|
||||
|
||||
/**
|
||||
* ViewModel subclass that adds a title property taken from the Model's name.
|
||||
*/
|
||||
public class TitledViewModel extends ViewModel {
|
||||
|
||||
private final StringProperty title = new SimpleStringProperty();
|
||||
|
||||
/**
|
||||
* Builds a ViewModel and sets the title from the person's name.
|
||||
*
|
||||
* @param model the Model instance
|
||||
*/
|
||||
public TitledViewModel(Object model) {
|
||||
super(model);
|
||||
Person person = (Person) model;
|
||||
title.set("titled-" + person.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the title property a Binding element addresses by name.
|
||||
*
|
||||
* @return the title property
|
||||
*/
|
||||
public StringProperty titleProperty() {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.neitzel.fx.view.component;
|
||||
|
||||
import de.neitzel.fx.view.HasViewModel;
|
||||
import de.neitzel.fx.view.ViewModel;
|
||||
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.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Component that shows one address.
|
||||
* <p>
|
||||
* The container injects the caption service. The street label shows that service's prefix.
|
||||
*/
|
||||
@Singleton
|
||||
public class AddressForm extends VBox implements HasViewModel {
|
||||
|
||||
private final StreetCaption captions;
|
||||
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
|
||||
|
||||
@FXML
|
||||
private Label streetLabel;
|
||||
|
||||
@FXML
|
||||
private TextField streetField;
|
||||
|
||||
private ViewModel receivedViewModel;
|
||||
private String streetTextAtHandOff;
|
||||
|
||||
/**
|
||||
* Creates a form that reads its street label from the injected caption.
|
||||
*
|
||||
* @param captions the service that supplies the street prefix
|
||||
*/
|
||||
@Inject
|
||||
public AddressForm(StreetCaption captions) {
|
||||
this.captions = captions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the injected prefix onto the street label.
|
||||
*/
|
||||
@FXML
|
||||
public void initialize() {
|
||||
streetLabel.setText(captions.prefix());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model property a parent View sets with a Direct binding.
|
||||
*
|
||||
* @return the model property
|
||||
*/
|
||||
public ObjectProperty<Object> modelProperty() {
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the address this component shows.
|
||||
*
|
||||
* @return the address, or {@code null} when none is set
|
||||
*/
|
||||
public Object getModel() {
|
||||
return model.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the address this component shows.
|
||||
*
|
||||
* @param model the address
|
||||
*/
|
||||
public void setModel(Object model) {
|
||||
this.model.set(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the ViewModel handed to this component, and the street text at that moment.
|
||||
*
|
||||
* @param viewModel the ViewModel selected for the address
|
||||
*/
|
||||
@Override
|
||||
public void setViewModel(ViewModel viewModel) {
|
||||
this.receivedViewModel = viewModel;
|
||||
this.streetTextAtHandOff = streetField.getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ViewModel this component was handed.
|
||||
*
|
||||
* @return the ViewModel, or {@code null} when none was handed over
|
||||
*/
|
||||
public ViewModel receivedViewModel() {
|
||||
return receivedViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the street text observed when the ViewModel was handed over.
|
||||
*
|
||||
* @return the street text at hand-off
|
||||
*/
|
||||
public String streetTextAtHandOff() {
|
||||
return streetTextAtHandOff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.neitzel.fx.view.component;
|
||||
|
||||
import de.neitzel.fx.view.fixture.Address;
|
||||
|
||||
/**
|
||||
* Ordinary model that holds one address, used to nest an address component.
|
||||
*/
|
||||
public class Envelope {
|
||||
|
||||
private Address address;
|
||||
|
||||
/**
|
||||
* Creates an envelope for the given address.
|
||||
*
|
||||
* @param address the address inside the envelope
|
||||
*/
|
||||
public Envelope(Address address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the address.
|
||||
*
|
||||
* @return the address
|
||||
*/
|
||||
public Address getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the address.
|
||||
*
|
||||
* @param address the address
|
||||
*/
|
||||
public void setAddress(Address address) {
|
||||
this.address = address;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.neitzel.fx.view.component;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Component that shows one envelope and the address component inside it.
|
||||
*/
|
||||
@Singleton
|
||||
public class EnvelopeForm extends VBox {
|
||||
|
||||
private final ObjectProperty<Object> model = new SimpleObjectProperty<>(this, "model");
|
||||
|
||||
/**
|
||||
* Returns the model property a parent View sets with a Direct binding.
|
||||
*
|
||||
* @return the model property
|
||||
*/
|
||||
public ObjectProperty<Object> modelProperty() {
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the envelope this component shows.
|
||||
*
|
||||
* @return the envelope, or {@code null} when none is set
|
||||
*/
|
||||
public Object getModel() {
|
||||
return model.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the envelope this component shows.
|
||||
*
|
||||
* @param model the envelope
|
||||
*/
|
||||
public void setModel(Object model) {
|
||||
this.model.set(model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.neitzel.fx.view.component;
|
||||
|
||||
/**
|
||||
* Ordinary model that holds one envelope.
|
||||
*/
|
||||
public class Letter {
|
||||
|
||||
private Envelope envelope;
|
||||
|
||||
/**
|
||||
* Creates a letter for the given envelope.
|
||||
*
|
||||
* @param envelope the envelope
|
||||
*/
|
||||
public Letter(Envelope envelope) {
|
||||
this.envelope = envelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the envelope.
|
||||
*
|
||||
* @return the envelope
|
||||
*/
|
||||
public Envelope getEnvelope() {
|
||||
return envelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the envelope.
|
||||
*
|
||||
* @param envelope the envelope
|
||||
*/
|
||||
public void setEnvelope(Envelope envelope) {
|
||||
this.envelope = envelope;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.neitzel.fx.view.component;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* Service whose text an address component must show, so a test can see that injection ran.
|
||||
*/
|
||||
@Singleton
|
||||
public class StreetCaption {
|
||||
|
||||
/**
|
||||
* Returns the prefix written on the street label.
|
||||
*
|
||||
* @return the prefix
|
||||
*/
|
||||
public String prefix() {
|
||||
return "Lane";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.neitzel.fx.view.computed;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.TextField;
|
||||
|
||||
/**
|
||||
* Controller that derives a label from the name field and does not write the Model.
|
||||
*/
|
||||
@Singleton
|
||||
public class WorkingAgeController {
|
||||
|
||||
@FXML
|
||||
private TextField nameField;
|
||||
|
||||
/**
|
||||
* Returns a literal chosen from the current name field.
|
||||
*
|
||||
* @return the value written to the label
|
||||
*/
|
||||
public String workingAge() {
|
||||
if ("Ada".equals(nameField.getText())) {
|
||||
return "initial-age";
|
||||
}
|
||||
if ("Grace".equals(nameField.getText())) {
|
||||
return "typed-age";
|
||||
}
|
||||
return "model-age";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Ordinary nested model used by view-loading tests.
|
||||
* <p>
|
||||
* Equality follows the street, so two addresses with the same street are equal while remaining
|
||||
* different instances.
|
||||
*/
|
||||
public class Address {
|
||||
|
||||
private String street;
|
||||
|
||||
/**
|
||||
* Creates an address with the given street.
|
||||
*
|
||||
* @param street the current street
|
||||
*/
|
||||
public Address(String street) {
|
||||
this.street = street;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the street.
|
||||
*
|
||||
* @return the street
|
||||
*/
|
||||
public String getStreet() {
|
||||
return street;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the street.
|
||||
*
|
||||
* @param street the street
|
||||
*/
|
||||
public void setStreet(String street) {
|
||||
this.street = street;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (other instanceof Address address) {
|
||||
return Objects.equals(street, address.street);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(street);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* Service whose text a converter must show, so a test can see that injection ran.
|
||||
*/
|
||||
@Singleton
|
||||
public class AgeUnit {
|
||||
|
||||
/**
|
||||
* Returns the prefix written in front of an age.
|
||||
*
|
||||
* @return the prefix
|
||||
*/
|
||||
public String prefix() {
|
||||
return "years:";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
import de.neitzel.fx.view.Converter;
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* Accepts a street written as {@code ok } plus the street, and rejects anything else.
|
||||
*/
|
||||
@Singleton
|
||||
public class CheckedStreetConverter implements Converter {
|
||||
|
||||
/**
|
||||
* Shows the street as it is stored.
|
||||
*
|
||||
* @param attributeValue the street
|
||||
* @return the control text
|
||||
*/
|
||||
@Override
|
||||
public Object toControlValue(Object attributeValue) {
|
||||
return attributeValue == null ? "" : attributeValue.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a street that starts with {@code ok }.
|
||||
*
|
||||
* @param controlValue the control text
|
||||
* @return the street
|
||||
*/
|
||||
@Override
|
||||
public Object toAttributeValue(Object controlValue) {
|
||||
String text = controlValue == null ? "" : controlValue.toString();
|
||||
if (!text.startsWith("ok ")) {
|
||||
throw new IllegalArgumentException("not a street: " + text);
|
||||
}
|
||||
return text.substring("ok ".length());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
/**
|
||||
* Ordinary model that holds a person, so a test can read a message two levels up.
|
||||
*/
|
||||
public class Household {
|
||||
|
||||
private Person person;
|
||||
|
||||
/**
|
||||
* Returns the person.
|
||||
*
|
||||
* @return the person, or {@code null} when the household has none
|
||||
*/
|
||||
public Person getPerson() {
|
||||
return person;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the person.
|
||||
*
|
||||
* @param person the person
|
||||
*/
|
||||
public void setPerson(Person person) {
|
||||
this.person = person;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.layout.Pane;
|
||||
|
||||
/**
|
||||
* A small control whose value is an object, used to bind a Complex attribute.
|
||||
*/
|
||||
public class ObjectHolder extends Pane {
|
||||
|
||||
private final ObjectProperty<Object> value = new SimpleObjectProperty<>(this, "value");
|
||||
|
||||
/**
|
||||
* Returns the value property.
|
||||
*
|
||||
* @return the value property
|
||||
*/
|
||||
public ObjectProperty<Object> valueProperty() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current value.
|
||||
*
|
||||
* @return the value
|
||||
*/
|
||||
public Object getValue() {
|
||||
return value.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value.
|
||||
*
|
||||
* @param value the value
|
||||
*/
|
||||
public void setValue(Object value) {
|
||||
this.value.set(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ordinary model used by view-loading tests.
|
||||
*/
|
||||
public class Person {
|
||||
|
||||
private String name;
|
||||
private Integer age;
|
||||
private boolean active;
|
||||
private Address address;
|
||||
private Address home;
|
||||
private Address work;
|
||||
private Person friend;
|
||||
private Role role;
|
||||
private LocalDate born;
|
||||
private Date updated;
|
||||
private Calendar hired;
|
||||
private List<String> tags;
|
||||
private List<Address> addresses;
|
||||
private Address selectedAddress;
|
||||
|
||||
/**
|
||||
* Creates a person with the given name.
|
||||
*
|
||||
* @param name the current name
|
||||
*/
|
||||
public Person(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name.
|
||||
*
|
||||
* @return the name
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the name.
|
||||
*
|
||||
* @param name the name
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a title that cannot be written.
|
||||
*
|
||||
* @return the title
|
||||
*/
|
||||
public String getTitle() {
|
||||
return "Doctor";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the age. The value may be null.
|
||||
*
|
||||
* @return the age
|
||||
*/
|
||||
public Integer getAge() {
|
||||
return age;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the age.
|
||||
*
|
||||
* @param age the age
|
||||
*/
|
||||
public void setAge(Integer age) {
|
||||
this.age = age;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the address.
|
||||
*
|
||||
* @return the address, or {@code null} when the person has none
|
||||
*/
|
||||
public Address getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the address.
|
||||
*
|
||||
* @param address the address
|
||||
*/
|
||||
public void setAddress(Address address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the home address.
|
||||
*
|
||||
* @return the home address
|
||||
*/
|
||||
public Address getHome() {
|
||||
return home;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the home address.
|
||||
*
|
||||
* @param home the home address
|
||||
*/
|
||||
public void setHome(Address home) {
|
||||
this.home = home;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the work address.
|
||||
*
|
||||
* @return the work address
|
||||
*/
|
||||
public Address getWork() {
|
||||
return work;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the work address.
|
||||
*
|
||||
* @param work the work address
|
||||
*/
|
||||
public void setWork(Address work) {
|
||||
this.work = work;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the friend.
|
||||
*
|
||||
* @return the friend
|
||||
*/
|
||||
public Person getFriend() {
|
||||
return friend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the friend.
|
||||
*
|
||||
* @param friend the friend
|
||||
*/
|
||||
public void setFriend(Person friend) {
|
||||
this.friend = friend;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
public Role getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the role.
|
||||
*
|
||||
* @param role the role
|
||||
*/
|
||||
public void setRole(Role role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the birth date.
|
||||
*
|
||||
* @return the birth date
|
||||
*/
|
||||
public LocalDate getBorn() {
|
||||
return born;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the birth date.
|
||||
*
|
||||
* @param born the birth date
|
||||
*/
|
||||
public void setBorn(LocalDate born) {
|
||||
this.born = born;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last update.
|
||||
*
|
||||
* @return the last update
|
||||
*/
|
||||
public Date getUpdated() {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last update.
|
||||
*
|
||||
* @param updated the last update
|
||||
*/
|
||||
public void setUpdated(Date updated) {
|
||||
this.updated = updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hire date.
|
||||
*
|
||||
* @return the hire date
|
||||
*/
|
||||
public Calendar getHired() {
|
||||
return hired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the hire date.
|
||||
*
|
||||
* @param hired the hire date
|
||||
*/
|
||||
public void setHired(Calendar hired) {
|
||||
this.hired = hired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tags. The value may be null.
|
||||
*
|
||||
* @return the tags
|
||||
*/
|
||||
public List<String> getTags() {
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the tags.
|
||||
*
|
||||
* @param tags the tags
|
||||
*/
|
||||
public void setTags(List<String> tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the addresses. The value may be null.
|
||||
*
|
||||
* @return the addresses
|
||||
*/
|
||||
public List<Address> getAddresses() {
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the addresses.
|
||||
*
|
||||
* @param addresses the addresses
|
||||
*/
|
||||
public void setAddresses(List<Address> addresses) {
|
||||
this.addresses = addresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the selected address. Null means nothing is selected.
|
||||
*
|
||||
* @return the selected address, or {@code null}
|
||||
*/
|
||||
public Address getSelectedAddress() {
|
||||
return selectedAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the selected address.
|
||||
*
|
||||
* @param selectedAddress the selected address, or {@code null} when nothing is selected
|
||||
*/
|
||||
public void setSelectedAddress(Address selectedAddress) {
|
||||
this.selectedAddress = selectedAddress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
import de.neitzel.fx.view.Converter;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* Converts an age using the prefix from {@link AgeUnit}.
|
||||
*/
|
||||
@Singleton
|
||||
public class PrefixedAgeConverter implements Converter {
|
||||
|
||||
private final AgeUnit ageUnit;
|
||||
|
||||
/**
|
||||
* Creates a converter that reads its prefix from the injected service.
|
||||
*
|
||||
* @param ageUnit the service that supplies the prefix
|
||||
*/
|
||||
@Inject
|
||||
public PrefixedAgeConverter(AgeUnit ageUnit) {
|
||||
this.ageUnit = ageUnit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the injected prefix in front of the age.
|
||||
*
|
||||
* @param attributeValue the age
|
||||
* @return the control text
|
||||
*/
|
||||
@Override
|
||||
public Object toControlValue(Object attributeValue) {
|
||||
return ageUnit.prefix() + attributeValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an age that starts with the injected prefix.
|
||||
*
|
||||
* @param controlValue the control text
|
||||
* @return the age
|
||||
*/
|
||||
@Override
|
||||
public Object toAttributeValue(Object controlValue) {
|
||||
String text = controlValue == null ? "" : controlValue.toString();
|
||||
String prefix = ageUnit.prefix();
|
||||
if (!text.startsWith(prefix)) {
|
||||
throw new IllegalArgumentException("not a whole number: " + text);
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(text.substring(prefix.length()));
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IllegalArgumentException("not a whole number: " + text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
/**
|
||||
* A simple enum Attribute used by view-loading tests.
|
||||
*/
|
||||
public enum Role {
|
||||
MEMBER,
|
||||
ADMIN
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.neitzel.fx.view.fixture;
|
||||
|
||||
import de.neitzel.fx.view.Converter;
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
/**
|
||||
* Shows an age and has no reverse conversion.
|
||||
*/
|
||||
@Singleton
|
||||
public class ShownAgeConverter implements Converter {
|
||||
|
||||
/**
|
||||
* Turns the age into label text.
|
||||
*
|
||||
* @param attributeValue the age
|
||||
* @return the label text
|
||||
*/
|
||||
@Override
|
||||
public Object toControlValue(Object attributeValue) {
|
||||
return "shown " + attributeValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.layout.StackPane?>
|
||||
<?import de.neitzel.fx.view.BindingList?>
|
||||
<?import de.neitzel.fx.view.DirectBinding?>
|
||||
<?import de.neitzel.fx.view.component.AddressForm?>
|
||||
|
||||
<StackPane xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="addressForm" property="model" path="address" direction="both"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<AddressForm fx:id="addressForm"/>
|
||||
</StackPane>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import de.neitzel.fx.view.BindingList?>
|
||||
<?import de.neitzel.fx.view.DirectBinding?>
|
||||
<?import de.neitzel.fx.view.fixture.ObjectHolder?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="addressHolder" property="value" path="address" direction="both"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<ObjectHolder fx:id="addressHolder" id="addressHolder"/>
|
||||
</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?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="streetField" property="text" path="address.street" direction="both"/>
|
||||
<DirectBinding control="streetLabel" property="text" path="address.street" direction="view"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<TextField fx:id="streetField" id="streetField"/>
|
||||
<Label fx:id="streetLabel" id="streetLabel"/>
|
||||
</VBox>
|
||||
@@ -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="streetField" property="text" path="address.street" direction="both"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<TextField fx:id="streetField" id="streetField"/>
|
||||
</VBox>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<?import javafx.scene.control.TextField?>
|
||||
<?import javafx.scene.layout.VBox?>
|
||||
<?import de.neitzel.fx.view.BindingList?>
|
||||
<?import de.neitzel.fx.view.DirectBinding?>
|
||||
|
||||
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
|
||||
<fx:define>
|
||||
<BindingList>
|
||||
<DirectBinding control="streetField" property="text" path="address.street" direction="both"
|
||||
converter="de.neitzel.fx.view.fixture.CheckedStreetConverter"/>
|
||||
</BindingList>
|
||||
</fx:define>
|
||||
<TextField fx:id="streetField" id="streetField"/>
|
||||
</VBox>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user