Compare commits
37
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 | ||
|
|
0c5b92bd76 | ||
|
|
edf02e9816 | ||
|
|
3178dc2723 | ||
|
|
5503dd192e | ||
|
|
9bbddaaae9 | ||
|
|
9c55794be8 | ||
|
|
93de125e67 | ||
|
|
6c1338d171 | ||
|
|
5aa75be6ba | ||
|
|
2b60038774 | ||
|
|
be6a8c853e |
@@ -0,0 +1,15 @@
|
|||||||
|
JavaDoc should be included for:
|
||||||
|
[]: # - All public, protected and private classes and interfaces
|
||||||
|
[]: # - All public, protected and private methods and constructors
|
||||||
|
[]: # - Any method that overrides a superclass method
|
||||||
|
[]: # 3. If JavaDoc is missing, please add it following these guidelines:
|
||||||
|
[]: # - Use clear and concise language to describe the purpose and functionality.
|
||||||
|
[]: # - Include descriptions for all parameters using @param tags.
|
||||||
|
[]: # - Include a description of the return value using @return tags.
|
||||||
|
[]: # - Mention any exceptions that the method might throw using @throws tags.
|
||||||
|
[]: # - Follow standard JavaDoc formatting conventions.
|
||||||
|
[]: # 4. After adding JavaDoc, review it for clarity and completeness.
|
||||||
|
[]: # 5. Commit your changes with a message indicating that JavaDoc has been added or updated.
|
||||||
|
[]: #
|
||||||
|
[]: # By following these instructions, we can ensure that our codebase remains well-documented and easy to understand
|
||||||
|
for all contributors.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
When working on a prompt, please follow these instructions:
|
||||||
|
|
||||||
|
1. When creating Code then just create the code required to complete the task. Do not include any explanations,
|
||||||
|
comments, or other text.
|
||||||
|
2. After code changes, check if any JavaDoc is missing. Please see JavaDoc-rules.md for guidelines on adding JavaDoc.
|
||||||
|
3. Create Unit Tests for the code you created or modified. Ensure that the tests cover various scenarios and edge cases.
|
||||||
|
4. When writing commit messages, please follow the guidelines in git-commit-instructions.md to ensure clarity and
|
||||||
|
consistency.
|
||||||
|
5. Ensure that your code adheres to the project's coding standards and best practices.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Git Commit Message Guidelines
|
||||||
|
|
||||||
|
When creating a commit message, please follow these guidelines to ensure clarity and consistency across the project:
|
||||||
|
|
||||||
|
1. **Structure of Commit Message**:
|
||||||
|
- The commit message should consist of a header, a body (optional), and a footer (optional).
|
||||||
|
- The header should be a single line that summarizes the changes made.
|
||||||
|
- If necessary, the body can provide additional context or details about the changes.
|
||||||
|
- The footer can include references to issues or breaking changes.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Please just respond with "Hello, World!"
|
||||||
+1
-1
@@ -14,5 +14,5 @@
|
|||||||
# KIND, either express or implied. See the License for the
|
# KIND, either express or implied. See the License for the
|
||||||
# specific language governing permissions and limitations
|
# specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
||||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
|
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
|
||||||
|
|||||||
@@ -2,24 +2,32 @@
|
|||||||
|
|
||||||
## A Java Utilities Library
|
## A Java Utilities Library
|
||||||
|
|
||||||
This repository is **not** a production-ready library, but rather a personal collection of small helpers, utilities, and ideas that I find useful or interesting as a Java developer.
|
This repository is **not** a production-ready library, but rather a personal collection of small helpers, utilities, and
|
||||||
|
ideas that I find useful or interesting as a Java developer.
|
||||||
|
|
||||||
It serves primarily as a **knowledge base** and **inspiration pool** for my own development work. You're welcome to explore, copy, modify, or improve whatever you find helpful.
|
It serves primarily as a **knowledge base** and **inspiration pool** for my own development work. You're welcome to
|
||||||
|
explore, copy, modify, or improve whatever you find helpful.
|
||||||
|
|
||||||
> ⚠️ Use at your own discretion — no guarantees of stability, backwards compatibility, or completeness.
|
> ⚠️ Use at your own discretion — no guarantees of stability, backwards compatibility, or completeness.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
This is free and unencumbered software released into the public domain. Please see the [License](LICENSE.md) for details.
|
|
||||||
|
This is free and unencumbered software released into the public domain. Please see the [License](LICENSE.md) for
|
||||||
|
details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Note on Content
|
## Note on Content
|
||||||
|
|
||||||
The content of this repository — including source code, documentation, and examples — was partially created with the assistance of ChatGPT (an AI model by OpenAI). The generated content has been reviewed, edited, and integrated into the overall context by me.
|
The content of this repository — including source code, documentation, and examples — was partially created with the
|
||||||
|
assistance of ChatGPT (an AI model by OpenAI). The generated content has been reviewed, edited, and integrated into the
|
||||||
|
overall context by me.
|
||||||
|
|
||||||
Responsibility for all content lies entirely with me. Any generated content is subject to [OpenAI’s Terms of Use](https://openai.com/policies/terms-of-use) and is — where possible — either in the public domain or usable under a license compatible with this project's license.
|
Responsibility for all content lies entirely with me. Any generated content is subject
|
||||||
|
to [OpenAI’s Terms of Use](https://openai.com/policies/terms-of-use) and is — where possible — either in the public
|
||||||
|
domain or usable under a license compatible with this project's license.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
@@ -35,16 +43,25 @@ Some encryption classes that use format-preserving-encryption.
|
|||||||
|
|
||||||
### fx
|
### 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
|
### 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
|
||||||
|
|
||||||
|
TypeAdapter for gson.
|
||||||
|
|
||||||
### log4j
|
### log4j
|
||||||
|
|
||||||
Stuff for log4j (Version 1). Please use log4j2 if you want to use log4j! (Or log with slf4j as I do in all other modules!)
|
Stuff for log4j (Version 1). Please use log4j2 if you want to use log4j! (Or log with slf4j as I do in all other
|
||||||
|
modules!)
|
||||||
|
|
||||||
### net
|
### net
|
||||||
|
|
||||||
Some network stuff using javax.mail as dependency.
|
Some network stuff using javax.mail as dependency.
|
||||||
|
|
||||||
|
### quarkus
|
||||||
|
|
||||||
|
Some additions for quarkus that might be useful.
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import java.util.Iterator;
|
|||||||
* The ArgumentProvider class is a helper utility for iterating over an array
|
* The ArgumentProvider class is a helper utility for iterating over an array
|
||||||
* of command-line arguments. It provides methods to check for available arguments
|
* of command-line arguments. It provides methods to check for available arguments
|
||||||
* and retrieve them in sequence.
|
* and retrieve them in sequence.
|
||||||
*
|
* <p>
|
||||||
* This class is designed to work seamlessly with a command-line parser
|
* This class is designed to work seamlessly with a command-line parser and handle
|
||||||
* and handle tokenized inputs efficiently. It implements the Iterator interface
|
* tokenized inputs efficiently. It implements the {@link Iterator} interface for ease
|
||||||
* for ease of iteration.
|
* of iteration and provides convenience methods for peeking ahead.
|
||||||
*/
|
*/
|
||||||
public class ArgumentProvider implements Iterator<String> {
|
public class ArgumentProvider implements Iterator<String> {
|
||||||
|
|
||||||
@@ -19,18 +19,15 @@ public class ArgumentProvider implements Iterator<String> {
|
|||||||
* and processing by the application logic. It is used within the {@code ArgumentProvider}
|
* and processing by the application logic. It is used within the {@code ArgumentProvider}
|
||||||
* class to iterate through and retrieve arguments systematically.
|
* class to iterate through and retrieve arguments systematically.
|
||||||
*/
|
*/
|
||||||
private String[] arguments;
|
private final String[] arguments;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks the current position within an array of arguments.
|
* Tracks the current position within an array of arguments.
|
||||||
*
|
* This variable is used to index into the arguments array, enabling sequential access
|
||||||
* This variable is used to index into the arguments array, enabling
|
* to command-line tokens. It is incremented as arguments are processed or retrieved
|
||||||
* sequential access to command-line tokens. It is incremented as arguments
|
* using methods such as {@code next()} or {@code peek()} in the {@link ArgumentProvider}
|
||||||
* are processed or retrieved using methods such as {@code next()} or {@code peek()}
|
* class. Its value starts at 0 and increases until it reaches the length of the provided
|
||||||
* in the {@link ArgumentProvider} class.
|
* arguments array, at which point iteration ends.
|
||||||
*
|
|
||||||
* Its value starts at 0 and increases until it reaches the length of
|
|
||||||
* the provided arguments array, at which point iteration ends.
|
|
||||||
*/
|
*/
|
||||||
private int current = 0;
|
private int current = 0;
|
||||||
|
|
||||||
@@ -49,15 +46,6 @@ public class ArgumentProvider implements Iterator<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if there are more arguments available to iterate over.
|
|
||||||
*
|
|
||||||
* @return true if there are additional arguments available; false otherwise.
|
|
||||||
*/
|
|
||||||
public boolean hasNext() {
|
|
||||||
return current < arguments.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if the specified number of additional arguments are available in the collection.
|
* Determines if the specified number of additional arguments are available in the collection.
|
||||||
*
|
*
|
||||||
@@ -68,6 +56,26 @@ public class ArgumentProvider implements Iterator<String> {
|
|||||||
return current + count <= arguments.length;
|
return current + count <= arguments.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next argument in the sequence without advancing the iterator.
|
||||||
|
* If there are no more arguments available, this method returns null.
|
||||||
|
*
|
||||||
|
* @return the next argument in the sequence or null if no arguments are available
|
||||||
|
*/
|
||||||
|
public String peek() {
|
||||||
|
if (!hasNext()) return null;
|
||||||
|
return arguments[current];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there are more arguments available to iterate over.
|
||||||
|
*
|
||||||
|
* @return true if there are additional arguments available; false otherwise.
|
||||||
|
*/
|
||||||
|
public boolean hasNext() {
|
||||||
|
return current < arguments.length;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the next argument in the sequence.
|
* Retrieves the next argument in the sequence.
|
||||||
* If no more arguments are available, returns {@code null}.
|
* If no more arguments are available, returns {@code null}.
|
||||||
@@ -81,15 +89,4 @@ public class ArgumentProvider implements Iterator<String> {
|
|||||||
current++;
|
current++;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the next argument in the sequence without advancing the iterator.
|
|
||||||
* If there are no more arguments available, this method returns null.
|
|
||||||
*
|
|
||||||
* @return the next argument in the sequence or null if no arguments are available
|
|
||||||
*/
|
|
||||||
public String peek() {
|
|
||||||
if (!hasNext()) return null;
|
|
||||||
return arguments[current];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import java.util.function.Consumer;
|
|||||||
* The `Parameter` class defines a command-line parameter with associated metadata,
|
* The `Parameter` class defines a command-line parameter with associated metadata,
|
||||||
* such as constraints on the number of values it accepts, descriptions, and aliases.
|
* such as constraints on the number of values it accepts, descriptions, and aliases.
|
||||||
* It also allows specifying callbacks to handle parameter processing.
|
* It also allows specifying callbacks to handle parameter processing.
|
||||||
*
|
* <p>
|
||||||
* This class is intended to be used as part of a command-line parser, enabling
|
* This class is intended to be used as part of a command-line parser, enabling
|
||||||
* structured handling of arguments provided via the command line.
|
* structured handling of arguments provided via the command line.
|
||||||
*/
|
*/
|
||||||
@@ -23,14 +23,141 @@ import java.util.function.Consumer;
|
|||||||
@Setter
|
@Setter
|
||||||
public class Parameter {
|
public class Parameter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `name` variable represents the primary identifier for a parameter.
|
||||||
|
* This string is used to uniquely identify a command-line parameter when parsing
|
||||||
|
* arguments. It serves as the key for registering and retrieving parameters in a
|
||||||
|
* command-line parser.
|
||||||
|
* <p>
|
||||||
|
* The value of `name` is case-insensitive and can be used alongside aliases to
|
||||||
|
* recognize and process parameters provided in the command-line input.
|
||||||
|
*/
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the minimum number of values required for this parameter.
|
||||||
|
* <p>
|
||||||
|
* This variable enforces a constraint on the number of arguments that must
|
||||||
|
* be provided for the parameter during command-line parsing. If the number of
|
||||||
|
* provided arguments is less than this value, an exception will be thrown during parsing.
|
||||||
|
* <p>
|
||||||
|
* It is primarily used in validation logic within the command-line parser
|
||||||
|
* to ensure the required input is received for proper processing of the parameter.
|
||||||
|
*/
|
||||||
|
private int minNumberValues;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the maximum number of values that the corresponding parameter can accept.
|
||||||
|
* <p>
|
||||||
|
* This field is used to validate and enforce constraints during command-line parsing.
|
||||||
|
* If more values than specified are provided for a parameter, the parser throws an exception
|
||||||
|
* to indicate a violation of this constraint.
|
||||||
|
* <p>
|
||||||
|
* A value of 0 implies that the parameter does not support any values, while a positive
|
||||||
|
* value indicates the exact maximum limit of acceptable values.
|
||||||
|
*/
|
||||||
|
private int maxNumberValues;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the parameter allows multiple entries.
|
||||||
|
* <p>
|
||||||
|
* If set to {@code true}, the parameter can be specified multiple times
|
||||||
|
* on the command-line. This allows the association of multiple values
|
||||||
|
* or options with the same parameter name.
|
||||||
|
* <p>
|
||||||
|
* If set to {@code false}, the parameter can be specified only once
|
||||||
|
* during command-line parsing.
|
||||||
|
*/
|
||||||
|
private boolean multipleEntries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A concise, human-readable description of the parameter's purpose and functionality.
|
||||||
|
* <p>
|
||||||
|
* This description provides a brief summary to help users understand the
|
||||||
|
* essential role of the parameter within the command-line parser. It is typically
|
||||||
|
* displayed in usage instructions, help messages, or command summaries.
|
||||||
|
*/
|
||||||
|
private String shortDescription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A detailed, extended description of the parameter's purpose and usage.
|
||||||
|
* <p>
|
||||||
|
* This description provides deeper context about what the parameter represents,
|
||||||
|
* how it fits into the overall command-line application, and any nuances
|
||||||
|
* associated with its use. It is displayed when help or additional documentation
|
||||||
|
* for a specific parameter is requested.
|
||||||
|
*/
|
||||||
|
private String longDescription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A callback function that processes a list of string arguments for a specific parameter.
|
||||||
|
* The callback is invoked during command-line parsing when a parameter is recognized,
|
||||||
|
* passing the associated values of the parameter for further processing.
|
||||||
|
* <p>
|
||||||
|
* The provided {@link Consumer} implementation defines the logic to
|
||||||
|
* handle or validate the values supplied for a command-line parameter. The values
|
||||||
|
* passed are determined based on the parameter's configuration (e.g., minimum and
|
||||||
|
* maximum number of associated values).
|
||||||
|
* <p>
|
||||||
|
* This field is typically set for the `Parameter` class to enable custom handling
|
||||||
|
* of parameter-specific logic, such as invoking a help command or performing
|
||||||
|
* business logic with the values parsed from the command-line arguments.
|
||||||
|
*/
|
||||||
|
private Consumer<List<String>> callback;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the parameter is designated as the help command.
|
||||||
|
* <p>
|
||||||
|
* When set to {@code true}, this parameter is treated as the special help command
|
||||||
|
* within the command-line parser. A parameter marked as the help command typically
|
||||||
|
* provides users with information about available options or detailed descriptions
|
||||||
|
* of specific commands when invoked. If this flag is enabled, a custom callback
|
||||||
|
* method for displaying help context is automatically associated with the parameter.
|
||||||
|
*/
|
||||||
|
private boolean isHelpCommand;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates whether the parameter is designated as the default parameter.
|
||||||
|
* <p>
|
||||||
|
* The default parameter is a special type of parameter that can accept arguments
|
||||||
|
* without an explicit prefix or identifier. It is used when a provided command-line
|
||||||
|
* argument does not match any defined parameter and does not start with a special
|
||||||
|
* character (e.g., '-') typically used to denote named parameters.
|
||||||
|
* <p>
|
||||||
|
* If this field is set to {@code true}, the parameter is treated as the default
|
||||||
|
* parameter. Only one parameter can be assigned this role within the parser.
|
||||||
|
* Attempting to assign multiple default parameters or a default parameter without
|
||||||
|
* accepting values (both `minNumberValues` and `maxNumberValues` set to 0) will
|
||||||
|
* result in an exception during configuration.
|
||||||
|
* <p>
|
||||||
|
* This property is utilized during the parsing process to determine whether an
|
||||||
|
* unmatched argument should be handled as a default parameter value, simplifying
|
||||||
|
* the handling of positional arguments or other unnamed input data.
|
||||||
|
*/
|
||||||
|
private boolean isDefaultParameter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of alias names associated with a parameter.
|
||||||
|
* <p>
|
||||||
|
* This variable holds alternative names that can be used
|
||||||
|
* to reference the parameter in command-line input. Each alias
|
||||||
|
* functions as an equivalent to the primary parameter name.
|
||||||
|
* Aliases are case-insensitive when used within the command-line parser.
|
||||||
|
* <p>
|
||||||
|
* The aliases associated with a parameter allow greater flexibility
|
||||||
|
* and user convenience when specifying parameters during command-line execution.
|
||||||
|
*/
|
||||||
|
@Singular("alias")
|
||||||
|
private List<String> aliasList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a command-line parameter definition within the application.
|
* Represents a command-line parameter definition within the application.
|
||||||
*
|
* <p>
|
||||||
* This class encapsulates the metadata and configuration of a command-line parameter,
|
* This class encapsulates the metadata and configuration of a command-line parameter,
|
||||||
* including its name, aliases, descriptions, value constraints, and behavior. Instances
|
* including its name, aliases, descriptions, value constraints, and behavior. Instances
|
||||||
* of this class are used to define and manage the parameters that the `Parser` class
|
* of this class are used to define and manage the parameters that the `Parser` class
|
||||||
* recognizes and processes.
|
* recognizes and processes.
|
||||||
*
|
* <p>
|
||||||
* A parameter can be configured as a default parameter or as a help command. The default
|
* A parameter can be configured as a default parameter or as a help command. The default
|
||||||
* parameter serves as a catch-all for unmatched command-line inputs, while the help
|
* parameter serves as a catch-all for unmatched command-line inputs, while the help
|
||||||
* command provides usage information for users.
|
* command provides usage information for users.
|
||||||
@@ -64,131 +191,4 @@ public class Parameter {
|
|||||||
this.isDefaultParameter = isDefaultParameter;
|
this.isDefaultParameter = isDefaultParameter;
|
||||||
this.aliasList = aliasList;
|
this.aliasList = aliasList;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The `name` variable represents the primary identifier for a parameter.
|
|
||||||
* This string is used to uniquely identify a command-line parameter when parsing
|
|
||||||
* arguments. It serves as the key for registering and retrieving parameters in a
|
|
||||||
* command-line parser.
|
|
||||||
*
|
|
||||||
* The value of `name` is case-insensitive and can be used alongside aliases to
|
|
||||||
* recognize and process parameters provided in the command-line input.
|
|
||||||
*/
|
|
||||||
private String name;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Specifies the minimum number of values required for this parameter.
|
|
||||||
*
|
|
||||||
* This variable enforces a constraint on the number of arguments that must
|
|
||||||
* be provided for the parameter during command-line parsing. If the number of
|
|
||||||
* provided arguments is less than this value, an exception will be thrown during parsing.
|
|
||||||
*
|
|
||||||
* It is primarily used in validation logic within the command-line parser
|
|
||||||
* to ensure the required input is received for proper processing of the parameter.
|
|
||||||
*/
|
|
||||||
private int minNumberValues;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Specifies the maximum number of values that the corresponding parameter can accept.
|
|
||||||
*
|
|
||||||
* This field is used to validate and enforce constraints during command-line parsing.
|
|
||||||
* If more values than specified are provided for a parameter, the parser throws an exception
|
|
||||||
* to indicate a violation of this constraint.
|
|
||||||
*
|
|
||||||
* A value of 0 implies that the parameter does not support any values, while a positive
|
|
||||||
* value indicates the exact maximum limit of acceptable values.
|
|
||||||
*/
|
|
||||||
private int maxNumberValues;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicates whether the parameter allows multiple entries.
|
|
||||||
*
|
|
||||||
* If set to {@code true}, the parameter can be specified multiple times
|
|
||||||
* on the command-line. This allows the association of multiple values
|
|
||||||
* or options with the same parameter name.
|
|
||||||
*
|
|
||||||
* If set to {@code false}, the parameter can be specified only once
|
|
||||||
* during command-line parsing.
|
|
||||||
*/
|
|
||||||
private boolean multipleEntries;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A concise, human-readable description of the parameter's purpose and functionality.
|
|
||||||
*
|
|
||||||
* This description provides a brief summary to help users understand the
|
|
||||||
* essential role of the parameter within the command-line parser. It is typically
|
|
||||||
* displayed in usage instructions, help messages, or command summaries.
|
|
||||||
*/
|
|
||||||
private String shortDescription;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A detailed, extended description of the parameter's purpose and usage.
|
|
||||||
*
|
|
||||||
* This description provides deeper context about what the parameter represents,
|
|
||||||
* how it fits into the overall command-line application, and any nuances
|
|
||||||
* associated with its use. It is displayed when help or additional documentation
|
|
||||||
* for a specific parameter is requested.
|
|
||||||
*/
|
|
||||||
private String longDescription;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A callback function that processes a list of string arguments for a specific parameter.
|
|
||||||
* The callback is invoked during command-line parsing when a parameter is recognized,
|
|
||||||
* passing the associated values of the parameter for further processing.
|
|
||||||
*
|
|
||||||
* The provided {@link Consumer} implementation defines the logic to
|
|
||||||
* handle or validate the values supplied for a command-line parameter. The values
|
|
||||||
* passed are determined based on the parameter's configuration (e.g., minimum and
|
|
||||||
* maximum number of associated values).
|
|
||||||
*
|
|
||||||
* This field is typically set for the `Parameter` class to enable custom handling
|
|
||||||
* of parameter-specific logic, such as invoking a help command or performing
|
|
||||||
* business logic with the values parsed from the command-line arguments.
|
|
||||||
*/
|
|
||||||
private Consumer<List<String>> callback;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicates whether the parameter is designated as the help command.
|
|
||||||
*
|
|
||||||
* When set to {@code true}, this parameter is treated as the special help command
|
|
||||||
* within the command-line parser. A parameter marked as the help command typically
|
|
||||||
* provides users with information about available options or detailed descriptions
|
|
||||||
* of specific commands when invoked. If this flag is enabled, a custom callback
|
|
||||||
* method for displaying help context is automatically associated with the parameter.
|
|
||||||
*/
|
|
||||||
private boolean isHelpCommand;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicates whether the parameter is designated as the default parameter.
|
|
||||||
*
|
|
||||||
* The default parameter is a special type of parameter that can accept arguments
|
|
||||||
* without an explicit prefix or identifier. It is used when a provided command-line
|
|
||||||
* argument does not match any defined parameter and does not start with a special
|
|
||||||
* character (e.g., '-') typically used to denote named parameters.
|
|
||||||
*
|
|
||||||
* If this field is set to {@code true}, the parameter is treated as the default
|
|
||||||
* parameter. Only one parameter can be assigned this role within the parser.
|
|
||||||
* Attempting to assign multiple default parameters or a default parameter without
|
|
||||||
* accepting values (both `minNumberValues` and `maxNumberValues` set to 0) will
|
|
||||||
* result in an exception during configuration.
|
|
||||||
*
|
|
||||||
* This property is utilized during the parsing process to determine whether an
|
|
||||||
* unmatched argument should be handled as a default parameter value, simplifying
|
|
||||||
* the handling of positional arguments or other unnamed input data.
|
|
||||||
*/
|
|
||||||
private boolean isDefaultParameter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A list of alias names associated with a parameter.
|
|
||||||
*
|
|
||||||
* This variable holds alternative names that can be used
|
|
||||||
* to reference the parameter in command-line input. Each alias
|
|
||||||
* functions as an equivalent to the primary parameter name.
|
|
||||||
* Aliases are case-insensitive when used within the command-line parser.
|
|
||||||
*
|
|
||||||
* The aliases associated with a parameter allow greater flexibility
|
|
||||||
* and user convenience when specifying parameters during command-line execution.
|
|
||||||
*/
|
|
||||||
@Singular("alias")
|
|
||||||
private List<String> aliasList;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package de.neitzel.core.commandline;
|
|||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Parser class is responsible for parsing and handling command-line arguments.
|
* The Parser class is responsible for parsing and handling command-line arguments.
|
||||||
@@ -15,11 +19,11 @@ public class Parser {
|
|||||||
/**
|
/**
|
||||||
* A map to store parameters where the key is a string representing
|
* A map to store parameters where the key is a string representing
|
||||||
* the parameter name and the value is the corresponding {@link Parameter} object.
|
* the parameter name and the value is the corresponding {@link Parameter} object.
|
||||||
*
|
* <p>
|
||||||
* This map serves as a registry for defining, organizing, and accessing
|
* This map serves as a registry for defining, organizing, and accessing
|
||||||
* command-line parameters. Each entry holds a parameter's metadata and
|
* command-line parameters. Each entry holds a parameter's metadata and
|
||||||
* its associated configuration to facilitate effective command-line parsing.
|
* its associated configuration to facilitate effective command-line parsing.
|
||||||
*
|
* <p>
|
||||||
* Key considerations:
|
* Key considerations:
|
||||||
* - The key represents the primary name of the parameter.
|
* - The key represents the primary name of the parameter.
|
||||||
* - The value, encapsulated as a {@link Parameter} object, includes
|
* - The value, encapsulated as a {@link Parameter} object, includes
|
||||||
@@ -29,18 +33,18 @@ public class Parser {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The `defaultParameter` variable represents the default command-line parameter for a parser.
|
* The `defaultParameter` variable represents the default command-line parameter for a parser.
|
||||||
*
|
* <p>
|
||||||
* This parameter is used to capture arguments that do not explicitly match any named parameter
|
* This parameter is used to capture arguments that do not explicitly match any named parameter
|
||||||
* or alias. It acts as a catch-all for unnamed command-line input, often simplifying the handling
|
* or alias. It acts as a catch-all for unnamed command-line input, often simplifying the handling
|
||||||
* of positional arguments or other free-form input provided by the user.
|
* of positional arguments or other free-form input provided by the user.
|
||||||
*
|
* <p>
|
||||||
* It is essential to note:
|
* It is essential to note:
|
||||||
* - Only one parameter can be designated as the default parameter in a command-line parser.
|
* - Only one parameter can be designated as the default parameter in a command-line parser.
|
||||||
* - The parameter must allow values to be processed (e.g., its `minNumberValues` or `maxNumberValues`
|
* - The parameter must allow values to be processed (e.g., its `minNumberValues` or `maxNumberValues`
|
||||||
* should not disallow input).
|
* should not disallow input).
|
||||||
* - This parameter is automatically invoked if an input does not match any explicitly named
|
* - This parameter is automatically invoked if an input does not match any explicitly named
|
||||||
* parameter or alias in the parser configuration.
|
* parameter or alias in the parser configuration.
|
||||||
*
|
* <p>
|
||||||
* When set to `null`, the parser does not handle unmatched arguments, and unrecognized inputs
|
* When set to `null`, the parser does not handle unmatched arguments, and unrecognized inputs
|
||||||
* may result in an error or exception, depending on the parser's implementation.
|
* may result in an error or exception, depending on the parser's implementation.
|
||||||
*/
|
*/
|
||||||
@@ -48,12 +52,12 @@ public class Parser {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a new instance of the `Parser` class.
|
* Constructs a new instance of the `Parser` class.
|
||||||
*
|
* <p>
|
||||||
* The `Parser` class is responsible for parsing and processing
|
* The `Parser` class is responsible for parsing and processing
|
||||||
* command-line arguments. It interprets input data based on defined
|
* command-line arguments. It interprets input data based on defined
|
||||||
* parameters and provides structured access to arguments, enabling
|
* parameters and provides structured access to arguments, enabling
|
||||||
* streamlined application configuration and execution.
|
* streamlined application configuration and execution.
|
||||||
*
|
* <p>
|
||||||
* This constructor initializes the parser without any predefined
|
* This constructor initializes the parser without any predefined
|
||||||
* configuration or parameters. Users can define parameters,
|
* configuration or parameters. Users can define parameters,
|
||||||
* add handlers, and parse command-line arguments using available
|
* add handlers, and parse command-line arguments using available
|
||||||
@@ -71,7 +75,6 @@ public class Parser {
|
|||||||
* and other metadata such as whether it is a help command or default parameter.
|
* and other metadata such as whether it is a help command or default parameter.
|
||||||
* The parameter must fulfill specific constraints if defined as the default
|
* The parameter must fulfill specific constraints if defined as the default
|
||||||
* parameter (e.g., accepting values).
|
* parameter (e.g., accepting values).
|
||||||
*
|
|
||||||
* @throws IllegalArgumentException if the parameter is defined as a default parameter and
|
* @throws IllegalArgumentException if the parameter is defined as a default parameter and
|
||||||
* does not accept values (both minNumberValues and maxNumberValues
|
* does not accept values (both minNumberValues and maxNumberValues
|
||||||
* are set to 0).
|
* are set to 0).
|
||||||
@@ -98,22 +101,39 @@ public class Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether a given parameter string matches a valid parameter within the defined constraints.
|
* Handles the help command when invoked in the command-line interface, providing information about available parameters.
|
||||||
|
* If no arguments are provided, it lists all possible parameters and their short descriptions.
|
||||||
|
* If specific arguments are supplied, it provides the detailed description of the corresponding parameter.
|
||||||
|
* In case an unknown parameter is specified, it indicates so to the user.
|
||||||
*
|
*
|
||||||
* @param param the parameter string to be checked. This can either be a key defined in the `parameters` map
|
* @param arguments a list of strings representing the user-provided arguments. If empty or {@code null},
|
||||||
* or a potential default parameter if it does not start with a dash ("-").
|
* the method lists all available parameters with their short descriptions. If a parameter name
|
||||||
* @return {@code true} if the parameter is a valid entry in the `parameters` map or matches the default parameter;
|
* is provided in the list, its detailed information is displayed. If an unrecognized parameter
|
||||||
* {@code false} otherwise, including when the parameter starts with a dash ("-").
|
* is provided, a corresponding message is shown.
|
||||||
*/
|
*/
|
||||||
protected boolean isParameter(final String param) {
|
public void helpCommandCallback(final List<String> arguments) {
|
||||||
if (parameters.containsKey(param)) return true;
|
if (arguments == null || arguments.size() == 0) {
|
||||||
if (param.startsWith("-")) return false;
|
System.out.println("Moegliche Parameter der Applikation:");
|
||||||
return defaultParameter != null;
|
parameters.values().stream()
|
||||||
|
.distinct()
|
||||||
|
.sorted(Comparator.comparing(Parameter::getName))
|
||||||
|
.forEach(p -> System.out.println(p.getShortDescription()));
|
||||||
|
} else {
|
||||||
|
Parameter parameter = null;
|
||||||
|
if (parameters.containsKey(arguments.get(0))) parameter = parameters.get(arguments.get(0));
|
||||||
|
if (parameters.containsKey("-" + arguments.get(0))) parameter = parameters.get("-" + arguments.get(0));
|
||||||
|
|
||||||
|
if (parameter == null) {
|
||||||
|
System.out.println("Unbekannter Parameter: " + arguments.get(0));
|
||||||
|
} else {
|
||||||
|
System.out.println(parameter.getLongDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses an array of command-line arguments and processes them as defined by the application's parameters.
|
* Parses an array of command-line arguments and processes them as defined by the application's parameters.
|
||||||
*
|
* <p>
|
||||||
* The method iteratively checks each argument in the provided array, validates it against
|
* The method iteratively checks each argument in the provided array, validates it against
|
||||||
* the registered parameters, and invokes the corresponding processing callback if applicable.
|
* the registered parameters, and invokes the corresponding processing callback if applicable.
|
||||||
* Unrecognized parameters or missing required values will result in an {@code IllegalArgumentException}.
|
* Unrecognized parameters or missing required values will result in an {@code IllegalArgumentException}.
|
||||||
@@ -151,6 +171,20 @@ public class Parser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a given parameter string matches a valid parameter within the defined constraints.
|
||||||
|
*
|
||||||
|
* @param param the parameter string to be checked. This can either be a key defined in the `parameters` map
|
||||||
|
* or a potential default parameter if it does not start with a dash ("-").
|
||||||
|
* @return {@code true} if the parameter is a valid entry in the `parameters` map or matches the default parameter;
|
||||||
|
* {@code false} otherwise, including when the parameter starts with a dash ("-").
|
||||||
|
*/
|
||||||
|
protected boolean isParameter(final String param) {
|
||||||
|
if (parameters.containsKey(param)) return true;
|
||||||
|
if (param.startsWith("-")) return false;
|
||||||
|
return defaultParameter != null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves a list of options by consuming arguments from the provided {@link ArgumentProvider}
|
* Retrieves a list of options by consuming arguments from the provided {@link ArgumentProvider}
|
||||||
* within the range specified by the minimum and maximum values.
|
* within the range specified by the minimum and maximum values.
|
||||||
@@ -182,35 +216,4 @@ public class Parser {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the help command when invoked in the command-line interface, providing information about available parameters.
|
|
||||||
* If no arguments are provided, it lists all possible parameters and their short descriptions.
|
|
||||||
* If specific arguments are supplied, it provides the detailed description of the corresponding parameter.
|
|
||||||
* In case an unknown parameter is specified, it indicates so to the user.
|
|
||||||
*
|
|
||||||
* @param arguments a list of strings representing the user-provided arguments. If empty or {@code null},
|
|
||||||
* the method lists all available parameters with their short descriptions. If a parameter name
|
|
||||||
* is provided in the list, its detailed information is displayed. If an unrecognized parameter
|
|
||||||
* is provided, a corresponding message is shown.
|
|
||||||
*/
|
|
||||||
public void helpCommandCallback(final List<String> arguments) {
|
|
||||||
if (arguments == null || arguments.size() == 0) {
|
|
||||||
System.out.println("Moegliche Parameter der Applikation:");
|
|
||||||
parameters.values().stream()
|
|
||||||
.distinct()
|
|
||||||
.sorted(Comparator.comparing(Parameter::getName))
|
|
||||||
.forEach(p -> System.out.println(p.getShortDescription() ));
|
|
||||||
} else {
|
|
||||||
Parameter parameter = null;
|
|
||||||
if (parameters.containsKey(arguments.get(0))) parameter = parameters.get(arguments.get(0));
|
|
||||||
if (parameters.containsKey("-" + arguments.get(0))) parameter = parameters.get("-" + arguments.get(0));
|
|
||||||
|
|
||||||
if (parameter == null) {
|
|
||||||
System.out.println("Unbekannter Parameter: " + arguments.get(0));
|
|
||||||
} else {
|
|
||||||
System.out.println(parameter.getLongDescription());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.neitzel.core.config;
|
package de.neitzel.core.config;
|
||||||
|
|
||||||
import de.neitzel.core.util.FileUtils;
|
import de.neitzel.core.io.FileUtils;
|
||||||
import de.neitzel.core.util.Strings;
|
import de.neitzel.core.util.Strings;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ public class Configuration {
|
|||||||
* A {@link Properties} object that stores a set of key-value pairs.
|
* A {@link Properties} object that stores a set of key-value pairs.
|
||||||
* This variable can be used to manage configuration settings or other
|
* This variable can be used to manage configuration settings or other
|
||||||
* collections of properties within the application.
|
* collections of properties within the application.
|
||||||
*
|
* <p>
|
||||||
* It provides methods to load, retrieve, and modify properties
|
* It provides methods to load, retrieve, and modify properties
|
||||||
* as necessary for the application's requirements.
|
* as necessary for the application's requirements.
|
||||||
*/
|
*/
|
||||||
@@ -92,18 +92,6 @@ public class Configuration {
|
|||||||
return Strings.expandEnvironmentVariables(result);
|
return Strings.expandEnvironmentVariables(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the value of a string property associated with the specified key,
|
|
||||||
* removes any surrounding quotes from the value, and returns the resultant string.
|
|
||||||
*
|
|
||||||
* @param key the key associated with the desired property
|
|
||||||
* @param defaultValue the default value to return if the property is not found or is null
|
|
||||||
* @return the string property without surrounding quotes, or the defaultValue if the property is not found
|
|
||||||
*/
|
|
||||||
protected String getStringPropertyWithoutQuotes(final String key, final String defaultValue) {
|
|
||||||
return Strings.removeQuotes(getStringProperty(key, defaultValue));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the string value of a configuration property identified by the given key,
|
* Retrieves the string value of a configuration property identified by the given key,
|
||||||
* removes surrounding quotes if present, and expands any environment variables found
|
* removes surrounding quotes if present, and expands any environment variables found
|
||||||
@@ -118,6 +106,18 @@ public class Configuration {
|
|||||||
return Strings.expandEnvironmentVariables(result);
|
return Strings.expandEnvironmentVariables(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the value of a string property associated with the specified key,
|
||||||
|
* removes any surrounding quotes from the value, and returns the resultant string.
|
||||||
|
*
|
||||||
|
* @param key the key associated with the desired property
|
||||||
|
* @param defaultValue the default value to return if the property is not found or is null
|
||||||
|
* @return the string property without surrounding quotes, or the defaultValue if the property is not found
|
||||||
|
*/
|
||||||
|
protected String getStringPropertyWithoutQuotes(final String key, final String defaultValue) {
|
||||||
|
return Strings.removeQuotes(getStringProperty(key, defaultValue));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the integer value for the specified property key. If the key does
|
* Retrieves the integer value for the specified property key. If the key does
|
||||||
* not exist in the properties or the value is null/empty, the provided default
|
* not exist in the properties or the value is null/empty, the provided default
|
||||||
|
|||||||
@@ -17,20 +17,13 @@ import java.io.InputStream;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class ImageScaler {
|
public class ImageScaler {
|
||||||
|
|
||||||
/**
|
|
||||||
* Private constructor to prevent instantiation of this utility class.
|
|
||||||
*/
|
|
||||||
private ImageScaler() {
|
|
||||||
throw new UnsupportedOperationException("Utility class cannot be instantiated");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specifies the target image format for scaling operations.
|
* Specifies the target image format for scaling operations.
|
||||||
*
|
* <p>
|
||||||
* This variable defines the file format that will be used when writing
|
* This variable defines the file format that will be used when writing
|
||||||
* or encoding the scaled image. The format must be compatible with
|
* or encoding the scaled image. The format must be compatible with
|
||||||
* Java's ImageIO framework (e.g., "png", "jpg", "bmp").
|
* Java's ImageIO framework (e.g., "png", "jpg", "bmp").
|
||||||
*
|
* <p>
|
||||||
* In this case, the target format is set to "png", allowing images
|
* In this case, the target format is set to "png", allowing images
|
||||||
* to be scaled and saved or returned as PNG files. It ensures
|
* to be scaled and saved or returned as PNG files. It ensures
|
||||||
* consistent output format across the image scaling methods in the class.
|
* consistent output format across the image scaling methods in the class.
|
||||||
@@ -38,8 +31,39 @@ public class ImageScaler {
|
|||||||
private static String TARGET_FORMAT = "png";
|
private static String TARGET_FORMAT = "png";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a scaled image from the given byte array representation and returns it as a byte array.
|
* Private constructor to prevent instantiation of this utility class.
|
||||||
|
*/
|
||||||
|
private ImageScaler() {
|
||||||
|
throw new UnsupportedOperationException("Utility class cannot be instantiated");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scales an image provided as a byte array to the specified dimensions and returns an InputStream of the scaled image.
|
||||||
|
* <p>
|
||||||
|
* The scaling behavior is determined by the enforceSize parameter:
|
||||||
|
* - If enforceSize is false, the provided dimensions are treated as maximum values, maintaining the original aspect ratio.
|
||||||
|
* - If enforceSize is true, the resulting image will match the exact dimensions, potentially with transparent borders to keep the original aspect ratio.
|
||||||
*
|
*
|
||||||
|
* @param originalImageBytes The byte array representing the original image.
|
||||||
|
* @param width The (maximum) width of the target scaled image.
|
||||||
|
* @param height The (maximum) height of the target scaled image.
|
||||||
|
* @param enforceSize A flag indicating whether to enforce the exact scaled dimensions. If false, the resulting image retains its aspect ratio.
|
||||||
|
* @return An InputStream representing the scaled image, or null if the operation fails or the input image is invalid.
|
||||||
|
*/
|
||||||
|
public static InputStream scaledImage(final byte[] originalImageBytes, final int width, final int height, final boolean enforceSize) {
|
||||||
|
// Get the scaled image.
|
||||||
|
byte[] bytes = createScaledImage(originalImageBytes, width, height, enforceSize);
|
||||||
|
|
||||||
|
// Validation of result.
|
||||||
|
if (bytes == null || bytes.length == 0) return null;
|
||||||
|
|
||||||
|
// Return new InputStream.
|
||||||
|
return new ByteArrayInputStream(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a scaled image from the given byte array representation and returns it as a byte array.
|
||||||
|
* <p>
|
||||||
* If enforceSize is set to false, the given dimensions are used as maximum values for scaling.
|
* If enforceSize is set to false, the given dimensions are used as maximum values for scaling.
|
||||||
* The resulting image may retain its aspect ratio and be smaller than the specified dimensions.
|
* The resulting image may retain its aspect ratio and be smaller than the specified dimensions.
|
||||||
* If enforceSize is true, the resulting image will match the exact dimensions, potentially adding
|
* If enforceSize is true, the resulting image will match the exact dimensions, potentially adding
|
||||||
@@ -66,33 +90,9 @@ public class ImageScaler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Scales an image provided as a byte array to the specified dimensions and returns an InputStream of the scaled image.
|
|
||||||
*
|
|
||||||
* The scaling behavior is determined by the enforceSize parameter:
|
|
||||||
* - If enforceSize is false, the provided dimensions are treated as maximum values, maintaining the original aspect ratio.
|
|
||||||
* - If enforceSize is true, the resulting image will match the exact dimensions, potentially with transparent borders to keep the original aspect ratio.
|
|
||||||
*
|
|
||||||
* @param originalImageBytes The byte array representing the original image.
|
|
||||||
* @param width The (maximum) width of the target scaled image.
|
|
||||||
* @param height The (maximum) height of the target scaled image.
|
|
||||||
* @param enforceSize A flag indicating whether to enforce the exact scaled dimensions. If false, the resulting image retains its aspect ratio.
|
|
||||||
* @return An InputStream representing the scaled image, or null if the operation fails or the input image is invalid.
|
|
||||||
*/
|
|
||||||
public static InputStream scaledImage(final byte[] originalImageBytes, final int width, final int height, final boolean enforceSize) {
|
|
||||||
// Get the scaled image.
|
|
||||||
byte[] bytes = createScaledImage(originalImageBytes, width, height, enforceSize);
|
|
||||||
|
|
||||||
// Validation of result.
|
|
||||||
if (bytes == null || bytes.length == 0) return null;
|
|
||||||
|
|
||||||
// Return new InputStream.
|
|
||||||
return new ByteArrayInputStream(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a scaled version of the given {@link BufferedImage} and returns it as a byte array.
|
* Creates a scaled version of the given {@link BufferedImage} and returns it as a byte array.
|
||||||
*
|
* <p>
|
||||||
* The scaling behavior is determined by the enforceSize parameter:
|
* The scaling behavior is determined by the enforceSize parameter:
|
||||||
* - If enforceSize is false, the provided dimensions are treated as maximum values, and the image
|
* - If enforceSize is false, the provided dimensions are treated as maximum values, and the image
|
||||||
* will maintain its original aspect ratio. The resulting image may be smaller than the specified
|
* will maintain its original aspect ratio. The resulting image may be smaller than the specified
|
||||||
|
|||||||
@@ -1,381 +0,0 @@
|
|||||||
package de.neitzel.core.inject;
|
|
||||||
|
|
||||||
import de.neitzel.core.inject.annotation.Component;
|
|
||||||
import de.neitzel.core.inject.annotation.Inject;
|
|
||||||
import org.reflections.Reflections;
|
|
||||||
|
|
||||||
import java.lang.reflect.Constructor;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* InjectableComponentScanner is responsible for scanning packages to detect classes annotated
|
|
||||||
* with @FXMLComponent and analyzing their compatibility for instantiation and dependency injection.
|
|
||||||
* The resulting analysis identifies unique and shared interfaces/superclasses as well as
|
|
||||||
* potentially instantiable components, collecting relevant errors if instantiation is not feasible.
|
|
||||||
*/
|
|
||||||
public class ComponentScanner {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A set that stores classes annotated with {@code @Component}, representing
|
|
||||||
* FXML-compatible components detected during the scanning process.
|
|
||||||
* These components are used as part of the dependency injection mechanism
|
|
||||||
* within the {@code InjectableComponentScanner}.
|
|
||||||
*
|
|
||||||
* The {@code fxmlComponents} set is populated during the component scanning
|
|
||||||
* phase by identifying all classes within a specified package hierarchy
|
|
||||||
* annotated with the {@code @Component} annotation. These classes serve
|
|
||||||
* as the primary source for further analysis, resolution, and instantiation
|
|
||||||
* in the scanning workflow.
|
|
||||||
*
|
|
||||||
* This field is immutable, ensuring thread safety and consistent state
|
|
||||||
* throughout the lifetime of the {@code InjectableComponentScanner}.
|
|
||||||
*/
|
|
||||||
private final Set<Class<?>> components = new HashSet<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A set of component types that are not uniquely associated with a single implementation.
|
|
||||||
*
|
|
||||||
* This collection is populated during the analysis of discovered components to identify
|
|
||||||
* types that have multiple implementations, preventing them from being uniquely resolved.
|
|
||||||
* For example, if multiple components implement the same interface, that interface will be
|
|
||||||
* added to this set.
|
|
||||||
*
|
|
||||||
* It is primarily used during the component registration process to avoid ambiguities
|
|
||||||
* in the dependency injection system, ensuring that only resolvable, uniquely identifiable
|
|
||||||
* components can be instantiated and injected.
|
|
||||||
*/
|
|
||||||
private final Set<Class<?>> notUniqueTypes = new HashSet<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A mapping of unique super types to their corresponding component classes.
|
|
||||||
* The key represents a super type (interface or abstract class), and the value
|
|
||||||
* is the specific implementation or subclass that is uniquely identified among
|
|
||||||
* the scanned components.
|
|
||||||
*
|
|
||||||
* This map is populated during the component analysis process. For each super type
|
|
||||||
* in the scanned components, if exactly one implementation is found, it is
|
|
||||||
* considered unique and added to this mapping. In case multiple implementations
|
|
||||||
* exist for a given super type, it is excluded from this map and classified as
|
|
||||||
* a non-unique type.
|
|
||||||
*
|
|
||||||
* This mapping is utilized for resolving dependencies and determining which components
|
|
||||||
* can be instantiated based on unique type information.
|
|
||||||
*/
|
|
||||||
private final Map<Class<?>, Class<?>> uniqueTypeToComponent = new HashMap<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A mapping of components that can be instantiated with their corresponding types.
|
|
||||||
* This map links a component's class (key) to the specific implementation class (value)
|
|
||||||
* that can be instantiated. The entries are resolved through the scanning and analysis
|
|
||||||
* of available component types, ensuring that only components with resolvable
|
|
||||||
* dependencies are included.
|
|
||||||
*
|
|
||||||
* This field is part of the dependency injection process, where components annotated
|
|
||||||
* with {@code @Component} or similar annotations are scanned, analyzed, and registered.
|
|
||||||
* The resolution process checks if a component can be instantiated based on its
|
|
||||||
* constructor dependencies being resolvable using known types or other registered components.
|
|
||||||
*/
|
|
||||||
private final Map<Class<?>, ComponentData> instantiableComponents = new HashMap<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A list of error messages encountered during the scanning and analysis
|
|
||||||
* of components in the dependency injection process.
|
|
||||||
*
|
|
||||||
* This list is populated when components cannot be resolved due to issues
|
|
||||||
* such as multiple implementations of a superclass or interface,
|
|
||||||
* unmet construction requirements, or unresolved dependencies.
|
|
||||||
*
|
|
||||||
* Errors added to this list provide detailed descriptions of the specific
|
|
||||||
* issues that prevent certain components from being instantiated correctly.
|
|
||||||
*/
|
|
||||||
private final List<String> errors = new ArrayList<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes a new instance of the {@code InjectableComponentScanner} class, which scans for injectable
|
|
||||||
* components within the specified base package and resolves the set of recognizable and instantiable components.
|
|
||||||
*
|
|
||||||
* @param basePackage the base package to scan for injectable components
|
|
||||||
*/
|
|
||||||
public ComponentScanner(String basePackage) {
|
|
||||||
scanForComponents(basePackage);
|
|
||||||
analyzeComponentTypes();
|
|
||||||
resolveInstantiableComponents();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scans the specified base package for classes annotated with {@link Component}.
|
|
||||||
* Identified component classes are added to a collection for further processing.
|
|
||||||
*
|
|
||||||
* @param basePackage the package to scan for component annotations
|
|
||||||
*/
|
|
||||||
private void scanForComponents(String basePackage) {
|
|
||||||
Reflections reflections = new Reflections(basePackage);
|
|
||||||
components.addAll(reflections.getTypesAnnotatedWith(Component.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyzes component types within the injected components and classifies them based on their
|
|
||||||
* inheritance hierarchy and relationships.
|
|
||||||
*
|
|
||||||
* This method performs the following operations:
|
|
||||||
* 1. Maps each component to all of its superclasses and interfaces.
|
|
||||||
* 2. Identifies which superclasses or interfaces have multiple implementing components.
|
|
||||||
* 3. Populates:
|
|
||||||
* - A list of superclasses or interfaces that are not uniquely linked to a single component.
|
|
||||||
* - A map where unique superclasses or interfaces are associated with a specific implementing component.
|
|
||||||
*
|
|
||||||
* The mappings are built using the following data structures:
|
|
||||||
* - A map from superclasses/interfaces to the list of components that implement or extend them.
|
|
||||||
* - A list of non-unique types where multiple components exist for the same superclass or interface.
|
|
||||||
* - A map of unique superclasses/interfaces to their corresponding component.
|
|
||||||
*
|
|
||||||
* This method is a key part of the component scanning and resolution process, facilitating
|
|
||||||
* the identification of potential instantiation ambiguities or conflicts.
|
|
||||||
*/
|
|
||||||
private void analyzeComponentTypes() {
|
|
||||||
Map<Class<?>, List<Class<?>>> superTypesMap = new HashMap<>();
|
|
||||||
|
|
||||||
for (Class<?> component : components) {
|
|
||||||
Set<Class<?>> allSuperTypes = getAllSuperTypes(component);
|
|
||||||
|
|
||||||
for (Class<?> superType : allSuperTypes) {
|
|
||||||
superTypesMap.computeIfAbsent(superType, k -> new ArrayList<>()).add(component);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (Map.Entry<Class<?>, List<Class<?>>> entry : superTypesMap.entrySet()) {
|
|
||||||
Class<?> superType = entry.getKey();
|
|
||||||
List<Class<?>> implementations = entry.getValue();
|
|
||||||
|
|
||||||
if (implementations.size() > 1) {
|
|
||||||
notUniqueTypes.add(superType);
|
|
||||||
} else {
|
|
||||||
uniqueTypeToComponent.put(superType, implementations.get(0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves and identifies instantiable components from a set of scanned components.
|
|
||||||
* This process determines which components can be instantiated based on their dependencies
|
|
||||||
* and class relationships, while tracking unresolved types and potential conflicts.
|
|
||||||
*
|
|
||||||
* The resolution process involves:
|
|
||||||
* 1. Iteratively determining which components can be instantiated using the known types
|
|
||||||
* map. A component is resolvable if its dependencies can be satisfied by the current
|
|
||||||
* set of known types.
|
|
||||||
* 2. Registering resolvable components and their superclasses/interfaces into the known
|
|
||||||
* types map for future iterations.
|
|
||||||
* 3. Removing successfully resolved components from the unresolved set.
|
|
||||||
* 4. Repeating the process until no further components can be resolved in a given iteration.
|
|
||||||
*
|
|
||||||
* At the end of the resolution process:
|
|
||||||
* - Resolvable components are added to the `instantiableComponents` map, which maps types
|
|
||||||
* to their corresponding instantiable implementations.
|
|
||||||
* - Unresolved components are identified, and error details are collected to highlight
|
|
||||||
* dependencies or conflicts preventing their instantiation.
|
|
||||||
*
|
|
||||||
* If errors are encountered due to unresolved components, they are logged for further analysis.
|
|
||||||
*/
|
|
||||||
private void resolveInstantiableComponents() {
|
|
||||||
Set<Class<?>> resolved = new HashSet<>();
|
|
||||||
Set<Class<?>> unresolved = new HashSet<>(components);
|
|
||||||
Map<Class<?>, ComponentData> knownTypes = new HashMap<>();
|
|
||||||
Set<Class<?>> resolvableNow;
|
|
||||||
|
|
||||||
do {
|
|
||||||
resolvableNow = unresolved.stream()
|
|
||||||
.filter(c -> canInstantiate(c, knownTypes.keySet()))
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
for (Class<?> clazz : resolvableNow) {
|
|
||||||
Component annotation = clazz.getAnnotation(Component.class);
|
|
||||||
ComponentData componentInfo = new ComponentData(clazz, annotation.scope());
|
|
||||||
|
|
||||||
resolved.add(clazz);
|
|
||||||
registerComponentWithSuperTypes(componentInfo, knownTypes);
|
|
||||||
}
|
|
||||||
|
|
||||||
unresolved.removeAll(resolvableNow);
|
|
||||||
} while (!resolvableNow.isEmpty());
|
|
||||||
|
|
||||||
instantiableComponents.putAll(knownTypes);
|
|
||||||
|
|
||||||
if (!unresolved.isEmpty()) {
|
|
||||||
collectInstantiationErrors(unresolved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers a component and its superclasses or interfaces in the provided map of known types.
|
|
||||||
* This method ensures that the component and its inheritance hierarchy are associated with the
|
|
||||||
* component's data unless the supertype is marked as non-unique.
|
|
||||||
*
|
|
||||||
* @param component the {@code ComponentData} instance representing the component to be registered
|
|
||||||
* @param knownTypes the map where component types and their data are stored
|
|
||||||
*/
|
|
||||||
private void registerComponentWithSuperTypes(ComponentData component, Map<Class<?>, ComponentData> knownTypes) {
|
|
||||||
knownTypes.put(component.getType(), component);
|
|
||||||
|
|
||||||
for (Class<?> superType : getAllSuperTypes(component.getType())) {
|
|
||||||
if (!notUniqueTypes.contains(superType)) {
|
|
||||||
knownTypes.put(superType, component);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines whether the specified component class can be instantiated based on the provided set
|
|
||||||
* of known types. A component is considered instantiable if it has at least one constructor where
|
|
||||||
* all parameter types are contained in the known types set or if it has a no-argument constructor.
|
|
||||||
* Additionally, all fields annotated with {@code @Inject} must have their types present in the
|
|
||||||
* known types set.
|
|
||||||
*
|
|
||||||
* @param component the class to check for instantiability
|
|
||||||
* @param knownTypes the set of currently known types that can be used to satisfy dependencies
|
|
||||||
* @return {@code true} if the component can be instantiated; {@code false} otherwise
|
|
||||||
*/
|
|
||||||
private boolean canInstantiate(Class<?> component, Set<Class<?>> knownTypes) {
|
|
||||||
boolean hasValidConstructor = false;
|
|
||||||
|
|
||||||
for (Constructor<?> constructor : component.getConstructors()) {
|
|
||||||
Class<?>[] paramTypes = constructor.getParameterTypes();
|
|
||||||
if (paramTypes.length == 0 || Arrays.stream(paramTypes).allMatch(knownTypes::contains)) {
|
|
||||||
hasValidConstructor = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasValidConstructor) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var field : component.getDeclaredFields()) {
|
|
||||||
if (field.isAnnotationPresent(Inject.class) && !knownTypes.contains(field.getType())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects the instantiation errors for a set of unresolved classes and appends
|
|
||||||
* detailed error messages to the internal error list. This method analyzes unresolved
|
|
||||||
* components based on their constructors, determining if all required types are
|
|
||||||
* instantiable or if there are conflicting types that could prevent instantiation.
|
|
||||||
*
|
|
||||||
* @param unresolved the set of classes for which instantiation errors are collected
|
|
||||||
*/
|
|
||||||
private void collectInstantiationErrors(Set<Class<?>> unresolved) {
|
|
||||||
for (Class<?> component : unresolved) {
|
|
||||||
StringBuilder errorMsg = new StringBuilder("Component cannot be instantiated: " + component.getName());
|
|
||||||
|
|
||||||
boolean possibleWithUniqueTypes = false;
|
|
||||||
|
|
||||||
for (Constructor<?> constructor : component.getConstructors()) {
|
|
||||||
Class<?>[] paramTypes = constructor.getParameterTypes();
|
|
||||||
List<Class<?>> problematicTypes = Arrays.stream(paramTypes)
|
|
||||||
.filter(t -> !instantiableComponents.containsKey(t) && !notUniqueTypes.contains(t))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (problematicTypes.isEmpty()) {
|
|
||||||
possibleWithUniqueTypes = true;
|
|
||||||
} else {
|
|
||||||
errorMsg.append("\n ➤ Requires unknown types: ").append(problematicTypes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (possibleWithUniqueTypes) {
|
|
||||||
errorMsg.append("\n ➤ Could be instantiated if multiple implementations of ")
|
|
||||||
.append("interfaces/superclasses were resolved uniquely: ")
|
|
||||||
.append(getConflictingTypes(component));
|
|
||||||
}
|
|
||||||
|
|
||||||
errors.add(errorMsg.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Identifies and retrieves a comma-separated string of conflicting types for a given component.
|
|
||||||
* A conflicting type is a parameter type in the constructor of the given component
|
|
||||||
* that is already marked as not unique within the application context.
|
|
||||||
*
|
|
||||||
* @param component the class for which conflicting types need to be identified.
|
|
||||||
* @return a comma-separated string of fully qualified names of conflicting parameter types,
|
|
||||||
* or an empty string if no conflicts are found.
|
|
||||||
*/
|
|
||||||
private String getConflictingTypes(Class<?> component) {
|
|
||||||
return Arrays.stream(component.getConstructors())
|
|
||||||
.flatMap(constructor -> Arrays.stream(constructor.getParameterTypes()))
|
|
||||||
.filter(notUniqueTypes::contains)
|
|
||||||
.map(Class::getName)
|
|
||||||
.collect(Collectors.joining(", "));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves all superclasses and interfaces of the specified class, excluding the {@code Object} class.
|
|
||||||
*
|
|
||||||
* @param clazz the class for which to retrieve all superclasses and interfaces
|
|
||||||
* @return a set of all superclasses and interfaces implemented by the specified class
|
|
||||||
*/
|
|
||||||
private Set<Class<?>> getAllSuperTypes(Class<?> clazz) {
|
|
||||||
Set<Class<?>> result = new HashSet<>();
|
|
||||||
Class<?> superClass = clazz.getSuperclass();
|
|
||||||
|
|
||||||
while (superClass != null && superClass != Object.class) {
|
|
||||||
result.add(superClass);
|
|
||||||
superClass = superClass.getSuperclass();
|
|
||||||
}
|
|
||||||
|
|
||||||
result.addAll(Arrays.asList(clazz.getInterfaces()));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a map of classes representing component types to their corresponding instantiable implementations.
|
|
||||||
*
|
|
||||||
* @return A map where the key is a class type and the value is the corresponding class implementation
|
|
||||||
* that can be instantiated.
|
|
||||||
*/
|
|
||||||
public Map<Class<?>, ComponentData> getInstantiableComponents() {
|
|
||||||
return instantiableComponents;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a set of types that have multiple implementations, making them
|
|
||||||
* non-unique. These types could not be uniquely resolved during the
|
|
||||||
* component scanning and analysis process.
|
|
||||||
*
|
|
||||||
* @return a set of classes representing the types that have multiple
|
|
||||||
* implementations and cannot be resolved uniquely
|
|
||||||
*/
|
|
||||||
public Set<Class<?>> getNotUniqueTypes() {
|
|
||||||
return notUniqueTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a mapping of types to their unique component implementations.
|
|
||||||
* This map includes only those types that have a single corresponding component
|
|
||||||
* implementation, ensuring no ambiguity in resolution.
|
|
||||||
*
|
|
||||||
* @return a map where the keys are types (e.g., interfaces or superclasses)
|
|
||||||
* and the values are their unique component implementations.
|
|
||||||
*/
|
|
||||||
public Map<Class<?>, Class<?>> getUniqueTypeToComponent() {
|
|
||||||
return uniqueTypeToComponent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a list of error messages recorded during the scanning and analysis process.
|
|
||||||
* The errors typically indicate issues such as components that cannot be instantiated due to
|
|
||||||
* missing dependencies, unresolvable component types, or multiple implementations of a type
|
|
||||||
* that are not uniquely resolved.
|
|
||||||
*
|
|
||||||
* @return a list of error messages explaining the issues encountered.
|
|
||||||
*/
|
|
||||||
public List<String> getErrors() {
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package de.neitzel.core.inject.annotation;
|
|
||||||
|
|
||||||
import de.neitzel.core.inject.Scope;
|
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
|
||||||
import java.lang.annotation.Retention;
|
|
||||||
import java.lang.annotation.RetentionPolicy;
|
|
||||||
import java.lang.annotation.Target;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicates that an annotated class is a "component" within a dependency injection framework.
|
|
||||||
* Classes annotated with {@code @Component} are recognized during the component scanning process
|
|
||||||
* as candidates for instantiation and management by the dependency injection framework.
|
|
||||||
*
|
|
||||||
* This annotation is typically used on classes that represent application-specific components,
|
|
||||||
* such as service implementations, controllers, or other objects intended to be instantiated
|
|
||||||
* and injected into other components during runtime.
|
|
||||||
*
|
|
||||||
* The annotation must be placed at the type level, and it is retained at runtime
|
|
||||||
* to allow for reflection-based scanning of classes within specified packages.
|
|
||||||
*
|
|
||||||
* Usage of this annotation assumes that there exists a component scanning mechanism
|
|
||||||
* that processes annotated classes and identifies their roles, dependencies, and hierarchy
|
|
||||||
* within the application's structure.
|
|
||||||
*/
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
@Target(ElementType.TYPE)
|
|
||||||
public @interface Component {
|
|
||||||
/**
|
|
||||||
* Defines the scope of a component within the dependency injection framework.
|
|
||||||
* The scope determines whether the component is instantiated as a singleton or
|
|
||||||
* as a prototype.
|
|
||||||
*
|
|
||||||
* @return the scope of the component, defaulting to {@code Scope.SINGLETON}.
|
|
||||||
*/
|
|
||||||
Scope scope() default Scope.SINGLETON;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package de.neitzel.core.inject.annotation;
|
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
|
||||||
import java.lang.annotation.Retention;
|
|
||||||
import java.lang.annotation.RetentionPolicy;
|
|
||||||
import java.lang.annotation.Target;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicates that a field is a candidate for dependency injection within
|
|
||||||
* a dependency injection framework. Fields annotated with {@code @Inject}
|
|
||||||
* are automatically populated with the required component instance during runtime,
|
|
||||||
* typically by the dependency injection container.
|
|
||||||
*
|
|
||||||
* This annotation must be applied at the field level and is retained at runtime
|
|
||||||
* to enable reflection-based identification and assignment of dependencies.
|
|
||||||
*
|
|
||||||
* The framework's dependency resolution mechanism identifies the appropriate
|
|
||||||
* instance to inject based on the field's type or custom configuration,
|
|
||||||
* ensuring loose coupling and easier testability.
|
|
||||||
*/
|
|
||||||
@Target(ElementType.FIELD)
|
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
|
||||||
public @interface Inject {
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package de.neitzel.core.io;
|
package de.neitzel.core.io;
|
||||||
|
|
||||||
import de.neitzel.core.util.FileUtils;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
import java.nio.charset.Charset;
|
import java.nio.charset.Charset;
|
||||||
import java.nio.charset.IllegalCharsetNameException;
|
import java.nio.charset.IllegalCharsetNameException;
|
||||||
|
|
||||||
@@ -12,7 +15,7 @@ import java.nio.charset.IllegalCharsetNameException;
|
|||||||
* for handling file encoding conversion transparently. If a file is detected to be in UTF-8 encoding,
|
* for handling file encoding conversion transparently. If a file is detected to be in UTF-8 encoding,
|
||||||
* this class converts it to the specified target encoding using a temporary file, then opens the reader
|
* this class converts it to the specified target encoding using a temporary file, then opens the reader
|
||||||
* with the converted encoding. If the file is already in the target encoding, it opens the reader directly.
|
* with the converted encoding. If the file is already in the target encoding, it opens the reader directly.
|
||||||
*
|
* <p>
|
||||||
* This class is useful for applications needing to process text files in specific encodings and ensures
|
* This class is useful for applications needing to process text files in specific encodings and ensures
|
||||||
* encoding compatibility.
|
* encoding compatibility.
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +28,7 @@ public class ConvertedEncodingFileReader extends InputStreamReader {
|
|||||||
* This encoding is primarily used to determine whether a file needs conversion
|
* This encoding is primarily used to determine whether a file needs conversion
|
||||||
* to the target format or can be read directly in its existing format.
|
* to the target format or can be read directly in its existing format.
|
||||||
* The default value is set to "ISO-8859-15".
|
* The default value is set to "ISO-8859-15".
|
||||||
*
|
* <p>
|
||||||
* Modifying this variable requires careful consideration, as it affects
|
* Modifying this variable requires careful consideration, as it affects
|
||||||
* the behavior of methods that rely on encoding validation, particularly
|
* the behavior of methods that rely on encoding validation, particularly
|
||||||
* in the process of detecting UTF-8 files or converting them during file reading.
|
* in the process of detecting UTF-8 files or converting them during file reading.
|
||||||
@@ -33,17 +36,16 @@ public class ConvertedEncodingFileReader extends InputStreamReader {
|
|||||||
private static String checkEncoding = "ISO-8859-15";
|
private static String checkEncoding = "ISO-8859-15";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the encoding that will be used to check the file encoding for compatibility.
|
* Constructs a ConvertedEncodingFileReader for reading a file with encoding conversion support.
|
||||||
* Throws an exception if the specified encoding is not valid or supported.
|
* This constructor takes the file path as a string and ensures the file's encoding is either
|
||||||
|
* converted to the specified target format or read directly if it matches the target format.
|
||||||
*
|
*
|
||||||
* @param encoding the name of the character encoding to set as the check encoding;
|
* @param filename the path to the file to be read
|
||||||
* it must be a valid and supported Charset.
|
* @param targetFormat the target encoding format to use for reading the file
|
||||||
* @throws IllegalCharsetNameException if the specified encoding is not valid or supported.
|
* @throws IOException if an I/O error occurs while accessing or reading the specified file
|
||||||
*/
|
*/
|
||||||
private static void setCheckEncoding(final String encoding) {
|
public ConvertedEncodingFileReader(final String filename, final String targetFormat) throws IOException {
|
||||||
if (Charset.forName(encoding) != null) throw new IllegalCharsetNameException("Encoding " + encoding + " is not supported!");
|
this(new File(filename), targetFormat);
|
||||||
|
|
||||||
checkEncoding = encoding;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,19 +62,6 @@ public class ConvertedEncodingFileReader extends InputStreamReader {
|
|||||||
super(createTargetFormatInputFileStream(file, targetFormat), targetFormat);
|
super(createTargetFormatInputFileStream(file, targetFormat), targetFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructs a ConvertedEncodingFileReader for reading a file with encoding conversion support.
|
|
||||||
* This constructor takes the file path as a string and ensures the file's encoding is either
|
|
||||||
* converted to the specified target format or read directly if it matches the target format.
|
|
||||||
*
|
|
||||||
* @param filename the path to the file to be read
|
|
||||||
* @param targetFormat the target encoding format to use for reading the file
|
|
||||||
* @throws IOException if an I/O error occurs while accessing or reading the specified file
|
|
||||||
*/
|
|
||||||
public ConvertedEncodingFileReader(final String filename, final String targetFormat) throws IOException {
|
|
||||||
this(new File(filename), targetFormat);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an input file stream for a given file, converting its encoding if necessary.
|
* Creates an input file stream for a given file, converting its encoding if necessary.
|
||||||
* If the file is not in UTF-8 encoding, a direct {@link FileInputStream} is returned for the file.
|
* If the file is not in UTF-8 encoding, a direct {@link FileInputStream} is returned for the file.
|
||||||
@@ -100,4 +89,19 @@ public class ConvertedEncodingFileReader extends InputStreamReader {
|
|||||||
return new FileInputStream(tempFile);
|
return new FileInputStream(tempFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the encoding that will be used to check the file encoding for compatibility.
|
||||||
|
* Throws an exception if the specified encoding is not valid or supported.
|
||||||
|
*
|
||||||
|
* @param encoding the name of the character encoding to set as the check encoding;
|
||||||
|
* it must be a valid and supported Charset.
|
||||||
|
* @throws IllegalCharsetNameException if the specified encoding is not valid or supported.
|
||||||
|
*/
|
||||||
|
private static void setCheckEncoding(final String encoding) {
|
||||||
|
if (Charset.forName(encoding) != null)
|
||||||
|
throw new IllegalCharsetNameException("Encoding " + encoding + " is not supported!");
|
||||||
|
|
||||||
|
checkEncoding = encoding;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-73
@@ -1,19 +1,64 @@
|
|||||||
package de.neitzel.core.util;
|
package de.neitzel.core.io;
|
||||||
|
|
||||||
|
import de.neitzel.core.util.ArrayUtils;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.BufferedReader;
|
||||||
|
import java.io.BufferedWriter;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.FileWriter;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility class for handling file operations, such as encoding checks, content reading/writing,
|
* A utility class for file-related operations. This class provides functionalities
|
||||||
* path manipulations, and file conversions.
|
* for handling files and directories in an efficient manner.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class FileUtils {
|
public class FileUtils {
|
||||||
|
/**
|
||||||
|
* Defines a standardized timestamp format for debugging purposes, specifically used for naming
|
||||||
|
* or identifying files with precise timestamps. The format is "yyyy-MM-dd_HH_mm_ss_SSS", which
|
||||||
|
* includes:
|
||||||
|
* - Year in four digits (yyyy)
|
||||||
|
* - Month in two digits (MM)
|
||||||
|
* - Day of the month in two digits (dd)
|
||||||
|
* - Hour in 24-hour format with two digits (HH)
|
||||||
|
* - Minutes in two digits (mm)
|
||||||
|
* - Seconds in two digits (ss)
|
||||||
|
* - Milliseconds in three digits (SSS)
|
||||||
|
* <p>
|
||||||
|
* This ensures timestamps are sortable and easily identifiable.
|
||||||
|
*/
|
||||||
|
public static final SimpleDateFormat DEBUG_FILE_TIMESTAMP_FORMAT = new SimpleDateFormat("yyyy-MM-dd_HH_mm_ss_SSS");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default encoding used for string checks and validations in the application.
|
||||||
|
* <p>
|
||||||
|
* This constant represents the `ISO-8859-15` encoding, which is a standardized
|
||||||
|
* character set encoding, commonly used in contexts where backward compatibility
|
||||||
|
* with `ISO-8859-1` is required, but with support for certain additional characters,
|
||||||
|
* such as the euro currency symbol (€).
|
||||||
|
*/
|
||||||
|
public static final String DEFAULT_CHECK_ENCODING = "ISO-8859-15";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the default buffer size used for data processing operations.
|
||||||
|
* <p>
|
||||||
|
* This constant represents the size of the buffer in bytes, set to 1024,
|
||||||
|
* and is typically utilized in input/output operations to optimize performance
|
||||||
|
* by reducing the number of read/write calls.
|
||||||
|
*/
|
||||||
|
public static final int BUFFER_SIZE = 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private constructor to prevent instantiation of the utility class.
|
* Private constructor to prevent instantiation of the utility class.
|
||||||
* This utility class is not meant to be instantiated, as it only provides
|
* This utility class is not meant to be instantiated, as it only provides
|
||||||
@@ -27,39 +72,15 @@ public class FileUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines a standardized timestamp format for debugging purposes, specifically used for naming
|
* Determines if the content of the given file is encoded in UTF-8.
|
||||||
* or identifying files with precise timestamps. The format is "yyyy-MM-dd_HH_mm_ss_SSS", which
|
|
||||||
* includes:
|
|
||||||
* - Year in four digits (yyyy)
|
|
||||||
* - Month in two digits (MM)
|
|
||||||
* - Day of the month in two digits (dd)
|
|
||||||
* - Hour in 24-hour format with two digits (HH)
|
|
||||||
* - Minutes in two digits (mm)
|
|
||||||
* - Seconds in two digits (ss)
|
|
||||||
* - Milliseconds in three digits (SSS)
|
|
||||||
*
|
*
|
||||||
* This ensures timestamps are sortable and easily identifiable.
|
* @param file The file to check for UTF-8 encoding. Must not be null.
|
||||||
|
* @return true if the file content is in UTF-8 encoding; false otherwise.
|
||||||
|
* @throws IOException If an I/O error occurs while reading the file.
|
||||||
*/
|
*/
|
||||||
public static final SimpleDateFormat DEBUG_FILE_TIMESTAMP_FORMAT = new SimpleDateFormat("yyyy-MM-dd_HH_mm_ss_SSS");
|
public static boolean isUTF8(final File file) throws IOException {
|
||||||
|
return isUTF8(file, DEFAULT_CHECK_ENCODING);
|
||||||
/**
|
}
|
||||||
* Default encoding used for string checks and validations in the application.
|
|
||||||
*
|
|
||||||
* This constant represents the `ISO-8859-15` encoding, which is a standardized
|
|
||||||
* character set encoding, commonly used in contexts where backward compatibility
|
|
||||||
* with `ISO-8859-1` is required, but with support for certain additional characters,
|
|
||||||
* such as the euro currency symbol (€).
|
|
||||||
*/
|
|
||||||
public static final String DEFAULT_CHECK_ENCODING = "ISO-8859-15";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Specifies the default buffer size used for data processing operations.
|
|
||||||
*
|
|
||||||
* This constant represents the size of the buffer in bytes, set to 1024,
|
|
||||||
* and is typically utilized in input/output operations to optimize performance
|
|
||||||
* by reducing the number of read/write calls.
|
|
||||||
*/
|
|
||||||
public static final int BUFFER_SIZE = 1024;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether the given file is encoded in UTF-8.
|
* Determines whether the given file is encoded in UTF-8.
|
||||||
@@ -97,7 +118,7 @@ public class FileUtils {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the provided file starts with a UTF-8 Byte Order Mark (BOM).
|
* Checks if the provided file starts with a UTF-8 Byte Order Mark (BOM).
|
||||||
*
|
* <p>
|
||||||
* This method reads the first character of the file using a reader that assumes
|
* This method reads the first character of the file using a reader that assumes
|
||||||
* UTF-8 encoding and checks if it matches the Unicode Byte Order Mark (U+FEFF).
|
* UTF-8 encoding and checks if it matches the Unicode Byte Order Mark (U+FEFF).
|
||||||
*
|
*
|
||||||
@@ -114,20 +135,9 @@ public class FileUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if the content of the given file is encoded in UTF-8.
|
|
||||||
*
|
|
||||||
* @param file The file to check for UTF-8 encoding. Must not be null.
|
|
||||||
* @return true if the file content is in UTF-8 encoding; false otherwise.
|
|
||||||
* @throws IOException If an I/O error occurs while reading the file.
|
|
||||||
*/
|
|
||||||
public static boolean isUTF8(final File file) throws IOException {
|
|
||||||
return isUTF8(file, DEFAULT_CHECK_ENCODING);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the content of a text file from one character encoding format to another.
|
* Converts the content of a text file from one character encoding format to another.
|
||||||
*
|
* <p>
|
||||||
* This method reads the input text file using the specified source encoding and writes
|
* This method reads the input text file using the specified source encoding and writes
|
||||||
* the content to the output text file in the specified target encoding.
|
* the content to the output text file in the specified target encoding.
|
||||||
*
|
*
|
||||||
@@ -167,19 +177,6 @@ public class FileUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a universal file reader for the specified file name.
|
|
||||||
* This method initializes and returns an InputStreamReader to read
|
|
||||||
* the content of the given file.
|
|
||||||
*
|
|
||||||
* @param filename The name or path of the file to be read.
|
|
||||||
* @return An InputStreamReader for reading the specified file.
|
|
||||||
* @throws IOException If an I/O error occurs while creating the file reader.
|
|
||||||
*/
|
|
||||||
public static InputStreamReader createUniversalFileReader(final String filename) throws IOException {
|
|
||||||
return createUniversalFileReader(new File(filename));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a universal file reader for the specified file and format.
|
* Creates a universal file reader for the specified file and format.
|
||||||
* The method resolves the file using its name and the expected format,
|
* The method resolves the file using its name and the expected format,
|
||||||
@@ -194,17 +191,6 @@ public class FileUtils {
|
|||||||
return createUniversalFileReader(new File(filename), expectedFormat);
|
return createUniversalFileReader(new File(filename), expectedFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a universal file reader for the specified file using the default encoding and configuration.
|
|
||||||
*
|
|
||||||
* @param file The file to be read. Must not be null.
|
|
||||||
* @return An InputStreamReader configured to read the specified file.
|
|
||||||
* @throws IOException If an I/O error occurs while creating the reader.
|
|
||||||
*/
|
|
||||||
public static InputStreamReader createUniversalFileReader(final File file) throws IOException {
|
|
||||||
return createUniversalFileReader(file, DEFAULT_CHECK_ENCODING, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a universal file reader for the specified file with an expected format.
|
* Creates a universal file reader for the specified file with an expected format.
|
||||||
* This method wraps the given file in an InputStreamReader for consistent character stream manipulation.
|
* This method wraps the given file in an InputStreamReader for consistent character stream manipulation.
|
||||||
@@ -246,7 +232,7 @@ public class FileUtils {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the parent directory of the given file or directory path.
|
* Retrieves the parent directory of the given file or directory path.
|
||||||
*
|
* <p>
|
||||||
* If the given path does not have a parent directory, it defaults to returning the
|
* If the given path does not have a parent directory, it defaults to returning the
|
||||||
* current directory represented by ".".
|
* current directory represented by ".".
|
||||||
*
|
*
|
||||||
@@ -282,6 +268,30 @@ public class FileUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a universal file reader for the specified file name.
|
||||||
|
* This method initializes and returns an InputStreamReader to read
|
||||||
|
* the content of the given file.
|
||||||
|
*
|
||||||
|
* @param filename The name or path of the file to be read.
|
||||||
|
* @return An InputStreamReader for reading the specified file.
|
||||||
|
* @throws IOException If an I/O error occurs while creating the file reader.
|
||||||
|
*/
|
||||||
|
public static InputStreamReader createUniversalFileReader(final String filename) throws IOException {
|
||||||
|
return createUniversalFileReader(new File(filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a universal file reader for the specified file using the default encoding and configuration.
|
||||||
|
*
|
||||||
|
* @param file The file to be read. Must not be null.
|
||||||
|
* @return An InputStreamReader configured to read the specified file.
|
||||||
|
* @throws IOException If an I/O error occurs while creating the reader.
|
||||||
|
*/
|
||||||
|
public static InputStreamReader createUniversalFileReader(final File file) throws IOException {
|
||||||
|
return createUniversalFileReader(file, DEFAULT_CHECK_ENCODING, true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes the given content to the specified file path. If the file already exists, it will be overwritten.
|
* Writes the given content to the specified file path. If the file already exists, it will be overwritten.
|
||||||
*
|
*
|
||||||
@@ -294,4 +304,60 @@ public class FileUtils {
|
|||||||
writer.write(content);
|
writer.write(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the specified file or directory. If the target is a directory, all its contents,
|
||||||
|
* including subdirectories and files, will be deleted recursively.
|
||||||
|
* If the target file or directory does not exist, the method immediately returns {@code true}.
|
||||||
|
*
|
||||||
|
* @param targetFile the {@code Path} of the file or directory to be deleted
|
||||||
|
* @return {@code true} if the target file or directory was successfully deleted,
|
||||||
|
* or if it does not exist; {@code false} if an error occurred during deletion
|
||||||
|
*/
|
||||||
|
public static boolean remove(Path targetFile) {
|
||||||
|
if (!Files.exists(targetFile)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Files.isDirectory(targetFile)) {
|
||||||
|
return removeDirectory(targetFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetFile.toFile().delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the specified directory and all its contents, including subdirectories and files.
|
||||||
|
* The method performs a recursive deletion, starting with the deepest entries in the directory tree.
|
||||||
|
* If the directory does not exist, the method immediately returns true.
|
||||||
|
*
|
||||||
|
* @param targetDir the {@code Path} of the directory to be deleted
|
||||||
|
* @return {@code true} if the directory and all its contents were successfully deleted
|
||||||
|
* or if the directory does not exist; {@code false} if an error occurred during deletion
|
||||||
|
*/
|
||||||
|
public static boolean removeDirectory(Path targetDir) {
|
||||||
|
if (!Files.exists(targetDir)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Files.isDirectory(targetDir)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.walk(targetDir)
|
||||||
|
.sorted((a, b) -> b.compareTo(a))
|
||||||
|
.forEach(path -> {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.neitzel.core.io;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility class for creating and managing a temporary directory.
|
||||||
|
* Instances of this class create a unique temporary directory on the filesystem
|
||||||
|
* that can safely be used during the runtime of a program.
|
||||||
|
* <p>
|
||||||
|
* The directory is automatically cleaned up when the instance is closed.
|
||||||
|
*/
|
||||||
|
public class TempDirectory implements AutoCloseable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The filesystem path representing the temporary directory managed by this instance.
|
||||||
|
* This path is initialized when the TempDirectory object is created and points
|
||||||
|
* to a unique, newly created directory.
|
||||||
|
* <p>
|
||||||
|
* The directory can be used to safely store temporary runtime files. It is automatically
|
||||||
|
* deleted along with its content when the associated TempDirectory object is closed.
|
||||||
|
*/
|
||||||
|
private final Path directory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a temporary directory with a unique name on the filesystem.
|
||||||
|
* The directory will have a prefix specified by the user and is intended
|
||||||
|
* to serve as a temporary workspace during the runtime of the program.
|
||||||
|
*
|
||||||
|
* @param prefix the prefix to be used for the name of the temporary directory
|
||||||
|
* @throws IOException if an I/O error occurs when creating the directory
|
||||||
|
*/
|
||||||
|
public TempDirectory(String prefix) throws IOException {
|
||||||
|
this.directory = Files.createTempDirectory(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the path of the temporary directory associated with this instance.
|
||||||
|
*
|
||||||
|
* @return the {@code Path} of the temporary directory
|
||||||
|
*/
|
||||||
|
public Path getDirectory() {
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the temporary directory by cleaning up its contents and deleting the directory itself.
|
||||||
|
* <p>
|
||||||
|
* This method ensures that all files and subdirectories within the temporary directory are
|
||||||
|
* deleted in a reverse order, starting from the deepest leaf in the directory tree. If
|
||||||
|
* the directory does not exist, the method will not perform any actions.
|
||||||
|
* <p>
|
||||||
|
* If an error occurs while deleting any file or directory, a RuntimeException is thrown
|
||||||
|
* with the details of the failure.
|
||||||
|
*
|
||||||
|
* @throws IOException if an I/O error occurs while accessing the directory or its contents.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void close() throws IOException {
|
||||||
|
if (Files.exists(directory)) {
|
||||||
|
try (Stream<Path> walk = Files.walk(directory)) {
|
||||||
|
walk.sorted(Comparator.reverseOrder())
|
||||||
|
.forEach(path -> {
|
||||||
|
try {
|
||||||
|
Files.delete(path);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to delete: " + path, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,20 @@ public class ToneGenerator {
|
|||||||
throw new UnsupportedOperationException("Utility class");
|
throw new UnsupportedOperationException("Utility class");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays one or more specified tones for a given duration.
|
||||||
|
* Each tone is played sequentially in the order they are provided.
|
||||||
|
*
|
||||||
|
* @param duration the duration of each tone in milliseconds
|
||||||
|
* @param tones the names of the tones to play, specified as a variable-length argument
|
||||||
|
* @throws LineUnavailableException if the audio line cannot be opened or accessed
|
||||||
|
*/
|
||||||
|
public static void playTone(int duration, String... tones) throws LineUnavailableException {
|
||||||
|
for (String tone : tones) {
|
||||||
|
playTone(tone, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plays a tone based on a predefined tone name for a specified duration.
|
* Plays a tone based on a predefined tone name for a specified duration.
|
||||||
*
|
*
|
||||||
@@ -64,18 +78,4 @@ public class ToneGenerator {
|
|||||||
line.drain();
|
line.drain();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Plays one or more specified tones for a given duration.
|
|
||||||
* Each tone is played sequentially in the order they are provided.
|
|
||||||
*
|
|
||||||
* @param duration the duration of each tone in milliseconds
|
|
||||||
* @param tones the names of the tones to play, specified as a variable-length argument
|
|
||||||
* @throws LineUnavailableException if the audio line cannot be opened or accessed
|
|
||||||
*/
|
|
||||||
public static void playTone(int duration, String... tones) throws LineUnavailableException {
|
|
||||||
for (String tone : tones) {
|
|
||||||
playTone(tone, duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,34 +7,23 @@ import java.util.HashMap;
|
|||||||
* with their corresponding frequencies in hertz (Hz). It allows users to
|
* with their corresponding frequencies in hertz (Hz). It allows users to
|
||||||
* retrieve the frequency of a tone based on its standard notation name,
|
* retrieve the frequency of a tone based on its standard notation name,
|
||||||
* such as "C3", "A4", or "G#5".
|
* such as "C3", "A4", or "G#5".
|
||||||
*
|
* <p>
|
||||||
* This class can be particularly useful in applications related to sound synthesis,
|
* This class can be particularly useful in applications related to sound synthesis,
|
||||||
* music theory, signal processing, and other audio-related domains that require precise
|
* music theory, signal processing, and other audio-related domains that require precise
|
||||||
* frequency information for specific tones.
|
* frequency information for specific tones.
|
||||||
*/
|
*/
|
||||||
public class ToneTable {
|
public class ToneTable {
|
||||||
/**
|
|
||||||
* Private constructor to prevent instantiation of the utility class.
|
|
||||||
* This utility class is not meant to be instantiated, as it only provides
|
|
||||||
* static utility methods for array-related operations.
|
|
||||||
*
|
|
||||||
* @throws UnsupportedOperationException always, to indicate that this class
|
|
||||||
* should not be instantiated.
|
|
||||||
*/
|
|
||||||
private ToneTable() {
|
|
||||||
throw new UnsupportedOperationException("Utility class");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A static map that associates written tone names with their corresponding frequencies in hertz (Hz).
|
* A static map that associates written tone names with their corresponding frequencies in hertz (Hz).
|
||||||
* The keys represent tone names (e.g., "C4", "D#5"), and the values are their respective frequencies.
|
* The keys represent tone names (e.g., "C4", "D#5"), and the values are their respective frequencies.
|
||||||
* This map serves as a reference for converting tone names into their precise frequency values,
|
* This map serves as a reference for converting tone names into their precise frequency values,
|
||||||
* which are used in applications such as tone generation or audio playback.
|
* which are used in applications such as tone generation or audio playback.
|
||||||
*
|
* <p>
|
||||||
* The map is a crucial component of the ToneTable class, providing quick lookup of frequencies
|
* The map is a crucial component of the ToneTable class, providing quick lookup of frequencies
|
||||||
* for predefined tone names.
|
* for predefined tone names.
|
||||||
*/
|
*/
|
||||||
private static final HashMap<String, Double> toneMap = new HashMap<>();
|
private static final HashMap<String, Double> toneMap = new HashMap<>();
|
||||||
|
|
||||||
static {
|
static {
|
||||||
toneMap.put("C0", 16.35);
|
toneMap.put("C0", 16.35);
|
||||||
toneMap.put("C#0", 17.32);
|
toneMap.put("C#0", 17.32);
|
||||||
@@ -191,6 +180,18 @@ public class ToneTable {
|
|||||||
toneMap.put("B8", 7902.13);
|
toneMap.put("B8", 7902.13);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private constructor to prevent instantiation of the utility class.
|
||||||
|
* This utility class is not meant to be instantiated, as it only provides
|
||||||
|
* static utility methods for array-related operations.
|
||||||
|
*
|
||||||
|
* @throws UnsupportedOperationException always, to indicate that this class
|
||||||
|
* should not be instantiated.
|
||||||
|
*/
|
||||||
|
private ToneTable() {
|
||||||
|
throw new UnsupportedOperationException("Utility class");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the frequency of the tone based on the given tone name.
|
* Retrieves the frequency of the tone based on the given tone name.
|
||||||
* If the tone name exists in the tone map, its corresponding frequency is returned.
|
* If the tone name exists in the tone map, its corresponding frequency is returned.
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import java.sql.Connection;
|
|||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.*;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Scanner;
|
||||||
|
import java.util.Spliterator;
|
||||||
|
import java.util.Spliterators;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
import java.util.stream.StreamSupport;
|
import java.util.stream.StreamSupport;
|
||||||
@@ -89,6 +96,20 @@ public class Query<T> {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes the SQL statement represented by the PreparedStatement created in
|
||||||
|
* the method {@code createPreparedStatement()}. This method is used to perform
|
||||||
|
* database operations such as updates or other actions that do not produce a
|
||||||
|
* result set.
|
||||||
|
*
|
||||||
|
* @throws SQLException if a database access error occurs or the SQL statement is invalid.
|
||||||
|
*/
|
||||||
|
public void execute() throws SQLException {
|
||||||
|
try (PreparedStatement ps = createPreparedStatement()) {
|
||||||
|
ps.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the query text for the current query instance.
|
* Sets the query text for the current query instance.
|
||||||
*
|
*
|
||||||
@@ -100,6 +121,21 @@ public class Query<T> {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and returns a PreparedStatement object configured with the query text
|
||||||
|
* and parameters defined for the current instance.
|
||||||
|
*
|
||||||
|
* @return a PreparedStatement object initialized with the query and parameter values
|
||||||
|
* @throws SQLException if a database access error occurs or the PreparedStatement cannot be created
|
||||||
|
*/
|
||||||
|
private PreparedStatement createPreparedStatement() throws SQLException {
|
||||||
|
PreparedStatement ps = connection.prepareStatement(queryText);
|
||||||
|
for (ParameterSetter setter : parameterSetters) {
|
||||||
|
setter.apply(ps);
|
||||||
|
}
|
||||||
|
return ps;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the query resource by loading its content from the given resource path.
|
* Sets the query resource by loading its content from the given resource path.
|
||||||
* The resource content is read and stored as the query text for the query object.
|
* The resource content is read and stored as the query text for the query object.
|
||||||
@@ -166,6 +202,22 @@ public class Query<T> {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a managed stream to the given handler and ensures proper resource closing after use.
|
||||||
|
* A {@code Stream} is opened and passed to the specified handler as an argument.
|
||||||
|
* The stream will be automatically closed when the handler finishes execution or throws an exception.
|
||||||
|
*
|
||||||
|
* @param handler a function which receives a {@code Stream<T>} and processes it, returning a result of type {@code R}.
|
||||||
|
* @param <R> the type of the result produced by the handler.
|
||||||
|
* @return the result returned by the handler after processing the stream.
|
||||||
|
* @throws SQLException if an SQL error occurs during the creation or handling of the stream.
|
||||||
|
*/
|
||||||
|
public <R> R withStream(Function<Stream<T>, R> handler) throws SQLException {
|
||||||
|
try (Stream<T> stream = stream()) {
|
||||||
|
return handler.apply(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a {@link Stream} from the result set obtained by executing a prepared statement.
|
* Creates a {@link Stream} from the result set obtained by executing a prepared statement.
|
||||||
* The result set is wrapped in a {@link TrimmingResultSet} to handle data trimming.
|
* The result set is wrapped in a {@link TrimmingResultSet} to handle data trimming.
|
||||||
@@ -179,35 +231,6 @@ public class Query<T> {
|
|||||||
return streamFromResultSet(trimmingResultSet, factory);
|
return streamFromResultSet(trimmingResultSet, factory);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and returns a PreparedStatement object configured with the query text
|
|
||||||
* and parameters defined for the current instance.
|
|
||||||
*
|
|
||||||
* @return a PreparedStatement object initialized with the query and parameter values
|
|
||||||
* @throws SQLException if a database access error occurs or the PreparedStatement cannot be created
|
|
||||||
*/
|
|
||||||
private PreparedStatement createPreparedStatement() throws SQLException {
|
|
||||||
PreparedStatement ps = connection.prepareStatement(queryText);
|
|
||||||
for (ParameterSetter setter : parameterSetters) {
|
|
||||||
setter.apply(ps);
|
|
||||||
}
|
|
||||||
return ps;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes the SQL statement represented by the PreparedStatement created in
|
|
||||||
* the method {@code createPreparedStatement()}. This method is used to perform
|
|
||||||
* database operations such as updates or other actions that do not produce a
|
|
||||||
* result set.
|
|
||||||
*
|
|
||||||
* @throws SQLException if a database access error occurs or the SQL statement is invalid.
|
|
||||||
*/
|
|
||||||
public void execute() throws SQLException {
|
|
||||||
try (PreparedStatement ps = createPreparedStatement()) {
|
|
||||||
ps.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a {@link ResultSet} into a {@link Stream} of objects created using the provided {@link Function}
|
* Converts a {@link ResultSet} into a {@link Stream} of objects created using the provided {@link Function}
|
||||||
* factory. The stream ensures resources such as the {@code ResultSet} and its associated statement are
|
* factory. The stream ensures resources such as the {@code ResultSet} and its associated statement are
|
||||||
@@ -220,6 +243,7 @@ public class Query<T> {
|
|||||||
private Stream<T> streamFromResultSet(final ResultSet rs, final Function<ResultSet, T> factory) {
|
private Stream<T> streamFromResultSet(final ResultSet rs, final Function<ResultSet, T> factory) {
|
||||||
Iterator<T> iterator = new Iterator<>() {
|
Iterator<T> iterator = new Iterator<>() {
|
||||||
private boolean hasNextChecked = false;
|
private boolean hasNextChecked = false;
|
||||||
|
|
||||||
private boolean hasNext;
|
private boolean hasNext;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -272,22 +296,6 @@ public class Query<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides a managed stream to the given handler and ensures proper resource closing after use.
|
|
||||||
* A {@code Stream} is opened and passed to the specified handler as an argument.
|
|
||||||
* The stream will be automatically closed when the handler finishes execution or throws an exception.
|
|
||||||
*
|
|
||||||
* @param handler a function which receives a {@code Stream<T>} and processes it, returning a result of type {@code R}.
|
|
||||||
* @param <R> the type of the result produced by the handler.
|
|
||||||
* @return the result returned by the handler after processing the stream.
|
|
||||||
* @throws SQLException if an SQL error occurs during the creation or handling of the stream.
|
|
||||||
*/
|
|
||||||
public <R> R withStream(Function<Stream<T>, R> handler) throws SQLException {
|
|
||||||
try (Stream<T> stream = stream()) {
|
|
||||||
return handler.apply(stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a functional interface that accepts three input arguments and allows for operations
|
* Represents a functional interface that accepts three input arguments and allows for operations
|
||||||
* that can throw an {@link SQLException}. This can be useful in scenarios where SQL-related
|
* that can throw an {@link SQLException}. This can be useful in scenarios where SQL-related
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -52,7 +52,7 @@ public class EnumUtil {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a delimited string containing Enum constant names into a list of Enum constants.
|
* Parses a delimited string containing Enum constant names into a list of Enum constants.
|
||||||
*
|
* <p>
|
||||||
* This method takes an Enum class and a string of flags, splits the string by commas
|
* This method takes an Enum class and a string of flags, splits the string by commas
|
||||||
* or whitespace, and converts each substring into an Enum constant of the given class.
|
* or whitespace, and converts each substring into an Enum constant of the given class.
|
||||||
* The flag names are case-insensitive and will be converted to uppercase before matching
|
* The flag names are case-insensitive and will be converted to uppercase before matching
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public class RegionalizationMessage {
|
|||||||
* which is used to retrieve locale-specific objects and messages.
|
* which is used to retrieve locale-specific objects and messages.
|
||||||
* It facilitates the process of internationalization by loading resources
|
* It facilitates the process of internationalization by loading resources
|
||||||
* such as text and messages from specified bundles based on the provided locale.
|
* such as text and messages from specified bundles based on the provided locale.
|
||||||
*
|
* <p>
|
||||||
* This variable is initialized in constructors of the `RegionalizationMessage` class
|
* This variable is initialized in constructors of the `RegionalizationMessage` class
|
||||||
* and is used internally by various methods to fetch localized messages.
|
* and is used internally by various methods to fetch localized messages.
|
||||||
*/
|
*/
|
||||||
@@ -57,6 +57,21 @@ public class RegionalizationMessage {
|
|||||||
return res.getString(key);
|
return res.getString(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a localized and formatted message from a resource bundle based on a key.
|
||||||
|
* If the key is not found in the resource bundle, a default message is used.
|
||||||
|
* The message supports parameter substitution using the supplied arguments.
|
||||||
|
*
|
||||||
|
* @param key The key used to retrieve the localized message from the resource bundle.
|
||||||
|
* @param defaultMessage The default message to be used if the key is not found in the resource bundle.
|
||||||
|
* @param params The parameters to substitute into the message placeholders.
|
||||||
|
* @return A formatted string containing the localized message with substituted parameters.
|
||||||
|
*/
|
||||||
|
public String getFormattedMessage(final String key, final String defaultMessage, final Object... params) {
|
||||||
|
MessageFormat format = new MessageFormat(getMessage(key, defaultMessage));
|
||||||
|
return format.format(params);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves a localized message for the given key from the resource bundle.
|
* Retrieves a localized message for the given key from the resource bundle.
|
||||||
* If the key is not found, the specified default message is returned.
|
* If the key is not found, the specified default message is returned.
|
||||||
@@ -72,19 +87,4 @@ public class RegionalizationMessage {
|
|||||||
|
|
||||||
return defaultMessage;
|
return defaultMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves a localized and formatted message from a resource bundle based on a key.
|
|
||||||
* If the key is not found in the resource bundle, a default message is used.
|
|
||||||
* The message supports parameter substitution using the supplied arguments.
|
|
||||||
*
|
|
||||||
* @param key The key used to retrieve the localized message from the resource bundle.
|
|
||||||
* @param defaultMessage The default message to be used if the key is not found in the resource bundle.
|
|
||||||
* @param params The parameters to substitute into the message placeholders.
|
|
||||||
* @return A formatted string containing the localized message with substituted parameters.
|
|
||||||
*/
|
|
||||||
public String getFormattedMessage(final String key, final String defaultMessage, final Object... params) {
|
|
||||||
MessageFormat format = new MessageFormat(getMessage(key, defaultMessage));
|
|
||||||
return format.format(params);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import java.util.Locale;
|
|||||||
/**
|
/**
|
||||||
* A utility class for measuring elapsed time. The StopWatch can be started, stopped, and queried for the elapsed time in various formats.
|
* A utility class for measuring elapsed time. The StopWatch can be started, stopped, and queried for the elapsed time in various formats.
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unused")
|
||||||
public class StopWatch {
|
public class StopWatch {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,12 +15,29 @@ public class StopWatch {
|
|||||||
* duration between the start and the stop or the current time.
|
* duration between the start and the stop or the current time.
|
||||||
*/
|
*/
|
||||||
private Instant startTime;
|
private Instant startTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents the stopping time of the stopwatch. This variable is set when the stopwatch is stopped and is used to calculate the elapsed
|
* Represents the stopping time of the stopwatch. This variable is set when the stopwatch is stopped and is used to calculate the elapsed
|
||||||
* duration between the start and stop times.
|
* duration between the start and stop times.
|
||||||
*/
|
*/
|
||||||
private Instant stopTime;
|
private Instant stopTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new StopWatch instance in the reset state.
|
||||||
|
*
|
||||||
|
* <p>The constructor does not start timing. Both {@link #startTime} and {@link #stopTime}
|
||||||
|
* are initialized to {@code null}. To begin measuring elapsed time, call {@link #start()}.
|
||||||
|
* After calling {@link #stop()}, retrieve the measured duration with {@link #getUsedTime()} or
|
||||||
|
* a human-readable string via {@link #getUsedTimeFormatted(boolean, boolean, boolean, boolean, boolean)}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>Note: This class is not synchronized; synchronize externally if the same instance
|
||||||
|
* is accessed from multiple threads.</p>
|
||||||
|
*/
|
||||||
|
public StopWatch() {
|
||||||
|
// default constructor only ...
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the stopwatch by recording the current time as the starting time. If the stopwatch was previously stopped or not yet started,
|
* Starts the stopwatch by recording the current time as the starting time. If the stopwatch was previously stopped or not yet started,
|
||||||
* this method resets the starting time to the current time and clears the stopping time. This method is typically called before measuring
|
* this method resets the starting time to the current time and clears the stopping time. This method is typically called before measuring
|
||||||
@@ -47,18 +65,15 @@ public class StopWatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the amount of time elapsed between the stopwatch's starting time and either its stopping time or the current time if the
|
* Provides a compact and human-readable formatted string representing the elapsed time measured by the stopwatch. The method formats the
|
||||||
* stopwatch is still running.
|
* duration using a combination of days, hours, minutes, seconds, and milliseconds. This compact format removes additional verbosity in
|
||||||
|
* favor of a shorter, more concise representation.
|
||||||
*
|
*
|
||||||
* @return the duration representing the elapsed time between the start and end points of the stopwatch.
|
* @return a compact formatted string representing the elapsed time, including days, hours, minutes, seconds, and milliseconds as
|
||||||
* @throws IllegalStateException if the stopwatch has not been started before calling this method.
|
* applicable
|
||||||
*/
|
*/
|
||||||
public Duration getUsedTime() {
|
public String getUsedTimeFormattedCompact() {
|
||||||
if (startTime == null) {
|
return getUsedTimeFormatted(true, true, true, true, true);
|
||||||
throw new IllegalStateException("StopWatch has not been started.");
|
|
||||||
}
|
|
||||||
Instant end = (stopTime != null) ? stopTime : Instant.now();
|
|
||||||
return Duration.between(startTime, end);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -93,13 +108,13 @@ public class StopWatch {
|
|||||||
if (includeDays && days > 0) {
|
if (includeDays && days > 0) {
|
||||||
sb.append(days).append("d ");
|
sb.append(days).append("d ");
|
||||||
}
|
}
|
||||||
if (includeHours && (hours > 0 || sb.length() > 0)) {
|
if (includeHours && (hours > 0 || !sb.isEmpty())) {
|
||||||
sb.append(hours).append("h ");
|
sb.append(hours).append("h ");
|
||||||
}
|
}
|
||||||
if (includeMinutes && (minutes > 0 || sb.length() > 0)) {
|
if (includeMinutes && (minutes > 0 || !sb.isEmpty())) {
|
||||||
sb.append(minutes).append("m ");
|
sb.append(minutes).append("m ");
|
||||||
}
|
}
|
||||||
if (includeSeconds && (seconds > 0 || sb.length() > 0)) {
|
if (includeSeconds && (seconds > 0 || !sb.isEmpty())) {
|
||||||
sb.append(seconds);
|
sb.append(seconds);
|
||||||
}
|
}
|
||||||
if (includeMillis) {
|
if (includeMillis) {
|
||||||
@@ -115,14 +130,17 @@ public class StopWatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides a compact and human-readable formatted string representing the elapsed time measured by the stopwatch. The method formats the
|
* Calculates the amount of time elapsed between the stopwatch's starting time and either its stopping time or the current time if the
|
||||||
* duration using a combination of days, hours, minutes, seconds, and milliseconds. This compact format removes additional verbosity in
|
* stopwatch is still running.
|
||||||
* favor of a shorter, more concise representation.
|
|
||||||
*
|
*
|
||||||
* @return a compact formatted string representing the elapsed time, including days, hours, minutes, seconds, and milliseconds as
|
* @return the duration representing the elapsed time between the start and end points of the stopwatch.
|
||||||
* applicable
|
* @throws IllegalStateException if the stopwatch has not been started before calling this method.
|
||||||
*/
|
*/
|
||||||
public String getUsedTimeFormattedCompact() {
|
public Duration getUsedTime() {
|
||||||
return getUsedTimeFormatted(true, true, true, true, true);
|
if (startTime == null) {
|
||||||
|
throw new IllegalStateException("StopWatch has not been started.");
|
||||||
|
}
|
||||||
|
Instant end = (stopTime != null) ? stopTime : Instant.now();
|
||||||
|
return Duration.between(startTime, end);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ import java.util.Map;
|
|||||||
* Utility class providing common string manipulation methods.
|
* Utility class providing common string manipulation methods.
|
||||||
*/
|
*/
|
||||||
public class Strings {
|
public class Strings {
|
||||||
|
/**
|
||||||
|
* A map that holds the system's environment variables. The keys represent
|
||||||
|
* environment variable names, and the values represent their corresponding
|
||||||
|
* values. It is initialized with the current system environment variables using
|
||||||
|
* {@link System#getenv()}.
|
||||||
|
* <p>
|
||||||
|
* This variable provides access to the underlying system environment, which can
|
||||||
|
* be used for various purposes such as configuration, path resolution, or
|
||||||
|
* dynamic value substitution.
|
||||||
|
* <p>
|
||||||
|
* Note: Modifications to this map will not affect the system environment, as it
|
||||||
|
* is a copy of the environment at the time of initialization.
|
||||||
|
*/
|
||||||
|
private static Map<String, String> environmentVariables = System.getenv();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private constructor to prevent instantiation of the utility class.
|
* Private constructor to prevent instantiation of the utility class.
|
||||||
* This utility class is not meant to be instantiated, as it only provides
|
* This utility class is not meant to be instantiated, as it only provides
|
||||||
@@ -20,21 +35,6 @@ public class Strings {
|
|||||||
throw new UnsupportedOperationException("Utility class");
|
throw new UnsupportedOperationException("Utility class");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A map that holds the system's environment variables. The keys represent
|
|
||||||
* environment variable names, and the values represent their corresponding
|
|
||||||
* values. It is initialized with the current system environment variables using
|
|
||||||
* {@link System#getenv()}.
|
|
||||||
*
|
|
||||||
* This variable provides access to the underlying system environment, which can
|
|
||||||
* be used for various purposes such as configuration, path resolution, or
|
|
||||||
* dynamic value substitution.
|
|
||||||
*
|
|
||||||
* Note: Modifications to this map will not affect the system environment, as it
|
|
||||||
* is a copy of the environment at the time of initialization.
|
|
||||||
*/
|
|
||||||
private static Map<String, String> environmentVariables = System.getenv();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expands environment variable placeholders within the provided text.
|
* Expands environment variable placeholders within the provided text.
|
||||||
* Placeholders in the format ${VARIABLE_NAME} are replaced with their
|
* Placeholders in the format ${VARIABLE_NAME} are replaced with their
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import org.w3c.dom.Node;
|
|||||||
|
|
||||||
import javax.xml.parsers.DocumentBuilder;
|
import javax.xml.parsers.DocumentBuilder;
|
||||||
import javax.xml.parsers.DocumentBuilderFactory;
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
import javax.xml.transform.*;
|
import javax.xml.transform.OutputKeys;
|
||||||
|
import javax.xml.transform.Result;
|
||||||
|
import javax.xml.transform.Source;
|
||||||
|
import javax.xml.transform.Transformer;
|
||||||
|
import javax.xml.transform.TransformerException;
|
||||||
|
import javax.xml.transform.TransformerFactory;
|
||||||
import javax.xml.transform.dom.DOMSource;
|
import javax.xml.transform.dom.DOMSource;
|
||||||
import javax.xml.transform.stream.StreamResult;
|
import javax.xml.transform.stream.StreamResult;
|
||||||
import javax.xml.transform.stream.StreamSource;
|
import javax.xml.transform.stream.StreamSource;
|
||||||
@@ -25,6 +30,16 @@ import java.util.Date;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class XmlUtils {
|
public class XmlUtils {
|
||||||
|
/**
|
||||||
|
* A {@link DateTimeFormatter} instance used for formatting or parsing dates in the "yyyy-MM-dd" pattern.
|
||||||
|
* <p>
|
||||||
|
* This formatter adheres to the XML date format standard (ISO-8601). It can be used
|
||||||
|
* to ensure consistent date representations in XML or other similar text-based formats.
|
||||||
|
* <p>
|
||||||
|
* Thread-safe and immutable, this formatter can be shared across multiple threads.
|
||||||
|
*/
|
||||||
|
public static final DateTimeFormatter XML_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private constructor to prevent instantiation of the utility class.
|
* Private constructor to prevent instantiation of the utility class.
|
||||||
* This utility class is not meant to be instantiated, as it only provides
|
* This utility class is not meant to be instantiated, as it only provides
|
||||||
@@ -38,25 +53,16 @@ public class XmlUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A {@link DateTimeFormatter} instance used for formatting or parsing dates in the "yyyy-MM-dd" pattern.
|
* Creates an XML element with the specified name and value, formatted as a date string.
|
||||||
|
* The date value is converted to an XML-compatible string format using a predefined formatter.
|
||||||
*
|
*
|
||||||
* This formatter adheres to the XML date format standard (ISO-8601). It can be used
|
* @param doc the XML document to which the element belongs; must not be null
|
||||||
* to ensure consistent date representations in XML or other similar text-based formats.
|
* @param name the name of the element to be created; must not be null or empty
|
||||||
*
|
* @param value the date value to be assigned to the created element; must not be null
|
||||||
* Thread-safe and immutable, this formatter can be shared across multiple threads.
|
* @return the created XML element containing the specified name and formatted date value
|
||||||
*/
|
*/
|
||||||
public static final DateTimeFormatter XML_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
public static Element createElement(final Document doc, final String name, final Date value) {
|
||||||
|
return createElement(doc, name, XML_DATE_FORMATTER.format(value.toInstant()));
|
||||||
/**
|
|
||||||
* Creates a new element in the provided XML document with the specified name.
|
|
||||||
*
|
|
||||||
* @param doc The XML document in which the new element is to be created. Must not be null.
|
|
||||||
* @param name The name of the element to be created. Must not be null or empty.
|
|
||||||
* @return The newly created element with the given name. Never returns null.
|
|
||||||
*/
|
|
||||||
public static Element createElement(final Document doc, final String name) {
|
|
||||||
log.debug("Creating a new element " + name);
|
|
||||||
return doc.createElement(name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,19 +85,6 @@ public class XmlUtils {
|
|||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an XML element with the specified name and value, formatted as a date string.
|
|
||||||
* The date value is converted to an XML-compatible string format using a predefined formatter.
|
|
||||||
*
|
|
||||||
* @param doc the XML document to which the element belongs; must not be null
|
|
||||||
* @param name the name of the element to be created; must not be null or empty
|
|
||||||
* @param value the date value to be assigned to the created element; must not be null
|
|
||||||
* @return the created XML element containing the specified name and formatted date value
|
|
||||||
*/
|
|
||||||
public static Element createElement(final Document doc, final String name, final Date value) {
|
|
||||||
return createElement(doc, name, XML_DATE_FORMATTER.format(value.toInstant()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an XML element with the specified name and optional value.
|
* Creates an XML element with the specified name and optional value.
|
||||||
*
|
*
|
||||||
@@ -127,6 +120,18 @@ public class XmlUtils {
|
|||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new element in the provided XML document with the specified name.
|
||||||
|
*
|
||||||
|
* @param doc The XML document in which the new element is to be created. Must not be null.
|
||||||
|
* @param name The name of the element to be created. Must not be null or empty.
|
||||||
|
* @return The newly created element with the given name. Never returns null.
|
||||||
|
*/
|
||||||
|
public static Element createElement(final Document doc, final String name) {
|
||||||
|
log.debug("Creating a new element " + name);
|
||||||
|
return doc.createElement(name);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new XML element with a specified name and value, adds it to the specified parent element,
|
* Creates a new XML element with a specified name and value, adds it to the specified parent element,
|
||||||
* and returns the created element.
|
* and returns the created element.
|
||||||
@@ -144,7 +149,6 @@ public class XmlUtils {
|
|||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a given XML string by applying proper indentation to enhance readability.
|
* Formats a given XML string by applying proper indentation to enhance readability.
|
||||||
* The method uses a transformer to process the XML input, applying indentation
|
* The method uses a transformer to process the XML input, applying indentation
|
||||||
@@ -171,8 +175,7 @@ public class XmlUtils {
|
|||||||
transformer.transform(xmlInput, xmlOutput);
|
transformer.transform(xmlInput, xmlOutput);
|
||||||
|
|
||||||
return xmlOutput.getWriter().toString();
|
return xmlOutput.getWriter().toString();
|
||||||
}
|
} catch (TransformerException e) {
|
||||||
catch(TransformerException e) {
|
|
||||||
log.error("Error in XML: " + e.getMessage() + "\n" + xmlStream, e);
|
log.error("Error in XML: " + e.getMessage() + "\n" + xmlStream, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,8 +208,7 @@ public class XmlUtils {
|
|||||||
String xmlString = stringWriter.toString();
|
String xmlString = stringWriter.toString();
|
||||||
log.info("MO Request: " + xmlString);
|
log.info("MO Request: " + xmlString);
|
||||||
return xmlString;
|
return xmlString;
|
||||||
}
|
} catch (TransformerException e) {
|
||||||
catch(TransformerException e) {
|
|
||||||
log.error("Error in XML Transformation: " + e.getMessage(), e);
|
log.error("Error in XML Transformation: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
package de.neitzel.core.inject.testcomponents.test1ok;
|
|
||||||
|
|
||||||
public class SuperClass {
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
package de.neitzel.core.inject.testcomponents.test1ok;
|
|
||||||
|
|
||||||
public interface TestInterface1_1 {
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
package de.neitzel.core.inject.testcomponents.test1ok;
|
|
||||||
|
|
||||||
public interface TestInterface1_2 {
|
|
||||||
}
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
package de.neitzel.core.inject.testcomponents.test1ok.sub;
|
|
||||||
|
|
||||||
import de.neitzel.core.inject.annotation.Component;
|
|
||||||
import de.neitzel.core.inject.testcomponents.test1ok.SuperClass;
|
|
||||||
import de.neitzel.core.inject.testcomponents.test1ok.TestInterface1_1;
|
|
||||||
import de.neitzel.core.inject.testcomponents.test1ok.TestInterface1_2;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class TestComponent1_2 extends SuperClass implements TestInterface1_1, TestInterface1_2 {
|
|
||||||
public TestComponent1_2() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,20 @@ package de.neitzel.encryption;
|
|||||||
|
|
||||||
import com.idealista.fpe.config.Alphabet;
|
import com.idealista.fpe.config.Alphabet;
|
||||||
|
|
||||||
public abstract class BaseAlphabet implements Alphabet {
|
|
||||||
/**
|
/**
|
||||||
* Replaces illegal characters of a string with a given replacement character
|
* Base implementation of the {@link Alphabet} interface providing common helper methods
|
||||||
* @param text Text to check.
|
* for working with character sets and validating characters against the alphabet.
|
||||||
* @param replacement Replacement character.
|
|
||||||
* @return String in which all characters was replaced.
|
|
||||||
*/
|
*/
|
||||||
public String replaceIllegalCharacters(String text, char replacement) {
|
public interface BaseAlphabet extends Alphabet {
|
||||||
|
/**
|
||||||
|
* Replaces illegal characters of a string with a given replacement character.
|
||||||
|
*
|
||||||
|
* @param text Text to check; may not be null
|
||||||
|
* @param replacement Replacement character to use for characters not present in the alphabet
|
||||||
|
* @return a new string in which every invalid character was replaced with the provided replacement
|
||||||
|
* @throws IllegalArgumentException if {@code replacement} is not a valid character in the alphabet
|
||||||
|
*/
|
||||||
|
default String replaceIllegalCharacters(String text, char replacement) {
|
||||||
// Validate
|
// Validate
|
||||||
if (!isValidCharacter(replacement)) throw new IllegalArgumentException("replacement");
|
if (!isValidCharacter(replacement)) throw new IllegalArgumentException("replacement");
|
||||||
|
|
||||||
@@ -26,10 +32,11 @@ public abstract class BaseAlphabet implements Alphabet {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a given Character is part of the alphabet.
|
* Checks if a given Character is part of the alphabet.
|
||||||
|
*
|
||||||
* @param character Character to check.
|
* @param character Character to check.
|
||||||
* @return true if character is valid, else false.
|
* @return true if character is valid, else false.
|
||||||
*/
|
*/
|
||||||
public boolean isValidCharacter(char character) {
|
default boolean isValidCharacter(char character) {
|
||||||
// Compare with all characters
|
// Compare with all characters
|
||||||
for (char ch : availableCharacters()) {
|
for (char ch : availableCharacters()) {
|
||||||
if (ch == character) return true;
|
if (ch == character) return true;
|
||||||
|
|||||||
@@ -7,47 +7,51 @@ import com.idealista.fpe.config.LengthRange;
|
|||||||
|
|
||||||
import javax.crypto.KeyGenerator;
|
import javax.crypto.KeyGenerator;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.Random;
|
import java.security.SecureRandom;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper class to deal with FF1Encryption using com.idealista:format-preserving-encryption:1.0.0.
|
* Helper class to deal with FF1Encryption using com.idealista:format-preserving-encryption:1.0.0.
|
||||||
*/
|
*/
|
||||||
|
@SuppressWarnings("unused")
|
||||||
public class FF1Encryption {
|
public class FF1Encryption {
|
||||||
|
|
||||||
/**
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
* Key to use for all encryption / unencryption
|
|
||||||
*/
|
|
||||||
private byte[] key;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Should to small strings be ignored?
|
* Key to use for all encryption / unencryption.
|
||||||
*
|
|
||||||
* If this is set to true, small strings (less than 2 characters) will not be encrypted!
|
|
||||||
*/
|
*/
|
||||||
private boolean ignoreToShortStrings;
|
private final byte[] key;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* tweak to use for encryption
|
* Should too small strings be ignored?
|
||||||
|
* If this is set to true, small strings (less than {@code minLength} characters)
|
||||||
|
* will not be encrypted or decrypted and will be returned unchanged.
|
||||||
*/
|
*/
|
||||||
private byte[] tweak;
|
private final boolean ignoreToShortStrings;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Domain used for encryption
|
* Tweak to use for encryption.
|
||||||
*/
|
*/
|
||||||
private Domain domain;
|
private final byte[] tweak;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format preserving encryption to use.
|
* Domain used for encryption.
|
||||||
*/
|
*/
|
||||||
private FormatPreservingEncryption encryption;
|
private final Domain domain;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimum length of a string.
|
* Format preserving encryption implementation to use.
|
||||||
*/
|
*/
|
||||||
private int minLength;
|
private final FormatPreservingEncryption encryption;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum length of a string for which encryption/decryption will be applied.
|
||||||
|
*/
|
||||||
|
private final int minLength;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new instance of FF1Encryption
|
* Creates a new instance of FF1Encryption
|
||||||
|
*
|
||||||
* @param key AES key to use.
|
* @param key AES key to use.
|
||||||
* @param tweak tweak to use for encryption / decryption
|
* @param tweak tweak to use for encryption / decryption
|
||||||
* @param ignoreToShortStrings Ignore strings that are to short.
|
* @param ignoreToShortStrings Ignore strings that are to short.
|
||||||
@@ -58,6 +62,7 @@ public class FF1Encryption {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new instance of FF1Encryption
|
* Creates a new instance of FF1Encryption
|
||||||
|
*
|
||||||
* @param key AES key to use.
|
* @param key AES key to use.
|
||||||
* @param tweak tweak to use for encryption / decryption
|
* @param tweak tweak to use for encryption / decryption
|
||||||
* @param ignoreToShortStrings Ignore strings that are to short.
|
* @param ignoreToShortStrings Ignore strings that are to short.
|
||||||
@@ -72,15 +77,45 @@ public class FF1Encryption {
|
|||||||
this.domain = domain;
|
this.domain = domain;
|
||||||
this.minLength = minLength;
|
this.minLength = minLength;
|
||||||
|
|
||||||
encryption = FormatPreservingEncryptionBuilder
|
this.encryption = FormatPreservingEncryptionBuilder
|
||||||
.ff1Implementation().withDomain(domain)
|
.ff1Implementation()
|
||||||
|
.withDomain(domain)
|
||||||
.withDefaultPseudoRandomFunction(key)
|
.withDefaultPseudoRandomFunction(key)
|
||||||
.withLengthRange(new LengthRange(minLength, maxLength))
|
.withLengthRange(new LengthRange(minLength, maxLength))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new AES key woth the given length.
|
||||||
|
*
|
||||||
|
* @param length Length of the key in bits. Must be 128, 192 or 256 bits
|
||||||
|
* @return Byte array of the new key.
|
||||||
|
*/
|
||||||
|
public static byte[] createNewKey(int length) {
|
||||||
|
try {
|
||||||
|
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
|
||||||
|
keyGen.init(length); // for example
|
||||||
|
return keyGen.generateKey().getEncoded();
|
||||||
|
} catch (NoSuchAlgorithmException ex) {
|
||||||
|
throw new RuntimeException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new tweak of the given length.
|
||||||
|
*
|
||||||
|
* @param length Number of bytes the new teeak should have.
|
||||||
|
* @return byte array with the new tweak.
|
||||||
|
*/
|
||||||
|
public static byte[] createNewTweak(int length) {
|
||||||
|
byte[] key = new byte[length];
|
||||||
|
RANDOM.nextBytes(key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypts a given text.
|
* Encrypts a given text.
|
||||||
|
*
|
||||||
* @param plainText Unencrypted text.
|
* @param plainText Unencrypted text.
|
||||||
* @return Encrypted text.
|
* @return Encrypted text.
|
||||||
*/
|
*/
|
||||||
@@ -97,6 +132,7 @@ public class FF1Encryption {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt a given text.
|
* Decrypt a given text.
|
||||||
|
*
|
||||||
* @param cipherText Encrypted text.
|
* @param cipherText Encrypted text.
|
||||||
* @return Decrypted text.
|
* @return Decrypted text.
|
||||||
*/
|
*/
|
||||||
@@ -110,31 +146,4 @@ public class FF1Encryption {
|
|||||||
// Return decrypted text.
|
// Return decrypted text.
|
||||||
return encryption.decrypt(cipherText, tweak);
|
return encryption.decrypt(cipherText, tweak);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new AES key woth the given length.
|
|
||||||
* @param length Length of the key in bits. Must be 128, 192 or 256 bits
|
|
||||||
* @return Byte array of the new key.
|
|
||||||
*/
|
|
||||||
public static byte[] createNewKey(int length) {
|
|
||||||
try {
|
|
||||||
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
|
|
||||||
keyGen.init(length); // for example
|
|
||||||
return keyGen.generateKey().getEncoded();
|
|
||||||
} catch (NoSuchAlgorithmException ex) {
|
|
||||||
throw new RuntimeException(ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new tweak of the given length.
|
|
||||||
* @param length Number of bytes the new teeak should have.
|
|
||||||
* @return byte array with the new tweak.
|
|
||||||
*/
|
|
||||||
public static byte[] createNewTweak(int length) {
|
|
||||||
Random random = new Random();
|
|
||||||
byte[] key = new byte[length];
|
|
||||||
random.nextBytes(key);
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package de.neitzel.encryption;
|
|||||||
/**
|
/**
|
||||||
* Main characters of the Alphabet.
|
* Main characters of the Alphabet.
|
||||||
*/
|
*/
|
||||||
public class MainAlphabet extends BaseAlphabet {
|
public class MainAlphabet implements BaseAlphabet {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All characters we want to use.
|
* All characters that are part of the alphabet used by the MainDomain.
|
||||||
|
* This array includes lowercase letters, uppercase letters, digits, a set of
|
||||||
|
* special characters, and German-specific characters.
|
||||||
*/
|
*/
|
||||||
private static final char[] ALL_CHARS = new char[]{
|
private static final char[] ALL_CHARS = new char[]{
|
||||||
// lowercase characters
|
// lowercase characters
|
||||||
@@ -21,16 +23,38 @@ public class MainAlphabet extends BaseAlphabet {
|
|||||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||||
|
|
||||||
// special characters
|
// special characters
|
||||||
'-', '_', '?', ' ', '#', '+', '/', '*', '!', '\"', '§', '$', '%', '&', '(', ')', '=', '@',
|
'-', '_', '?', ' ', '#', '+', '/', '*', '!', '"', '§', '$', '%', '&', '(', ')', '=', '@',
|
||||||
'€', ',', ';', '.', ':', '<', '>', '|', '\'', '\\', '{', '}', '[', ']',
|
'€', ',', ';', '.', ':', '<', '>', '|', '\'', '\\', '{', '}', '[', ']',
|
||||||
|
|
||||||
// german special characters
|
// german special characters
|
||||||
'ä', 'Ä', 'ö', 'Ö', 'ü', 'Ü', 'ß'
|
'ä', 'Ä', 'ö', 'Ö', 'ü', 'Ü', 'ß'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new MainAlphabet instance.
|
||||||
|
*
|
||||||
|
* <p>This constructor performs no runtime initialization because the
|
||||||
|
* alphabet is represented by the static {@code ALL_CHARS} array. Use
|
||||||
|
* {@link #availableCharacters()} to obtain the supported characters and
|
||||||
|
* {@link #radix()} to obtain the alphabet size.</p>
|
||||||
|
*
|
||||||
|
* <p> Important usage notes: </p>
|
||||||
|
* <ul>
|
||||||
|
* <li>The array returned by {@link #availableCharacters()} is the internal
|
||||||
|
* static array; callers must not modify it.</li>
|
||||||
|
* <li>The alphabet intentionally contains letters (lower and upper case),
|
||||||
|
* digits, common punctuation and a set of German-specific characters
|
||||||
|
* to support locale-sensitive operations.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public MainAlphabet() {
|
||||||
|
// default constructor only ...
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the available characters.
|
* Gets the available characters.
|
||||||
* @return
|
*
|
||||||
|
* @return an array containing all supported characters in the alphabet
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public char[] availableCharacters() {
|
public char[] availableCharacters() {
|
||||||
@@ -39,7 +63,8 @@ public class MainAlphabet extends BaseAlphabet {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the radix of the alphabet.
|
* Gets the radix of the alphabet.
|
||||||
* @return
|
*
|
||||||
|
* @return the number of characters in the alphabet (radix)
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Integer radix() {
|
public Integer radix() {
|
||||||
|
|||||||
@@ -4,26 +4,60 @@ import com.idealista.fpe.config.Alphabet;
|
|||||||
import com.idealista.fpe.config.Domain;
|
import com.idealista.fpe.config.Domain;
|
||||||
import com.idealista.fpe.config.GenericTransformations;
|
import com.idealista.fpe.config.GenericTransformations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain implementation that provides the alphabet and transformation helpers
|
||||||
|
* used by the format-preserving encryption implementation in this project.
|
||||||
|
* <p>
|
||||||
|
* This class delegates character-to-index and index-to-character transformations
|
||||||
|
* to a {@link GenericTransformations} helper configured with the {@link MainAlphabet}.
|
||||||
|
*/
|
||||||
public class MainDomain implements Domain {
|
public class MainDomain implements Domain {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The alphabet used by the domain for encoding/decoding characters.
|
||||||
|
*/
|
||||||
private Alphabet _alphabet;
|
private Alphabet _alphabet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper instance performing actual transformations between characters and indices.
|
||||||
|
*/
|
||||||
private GenericTransformations transformations;
|
private GenericTransformations transformations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new {@code MainDomain} instance and initializes its alphabet and transformation helper.
|
||||||
|
*/
|
||||||
public MainDomain() {
|
public MainDomain() {
|
||||||
_alphabet = new MainAlphabet();
|
_alphabet = new MainAlphabet();
|
||||||
transformations = new GenericTransformations(_alphabet.availableCharacters());
|
transformations = new GenericTransformations(_alphabet.availableCharacters());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the {@link Alphabet} implementation used by this domain.
|
||||||
|
*
|
||||||
|
* @return the alphabet used for transforming values in this domain
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Alphabet alphabet() {
|
public Alphabet alphabet() {
|
||||||
return _alphabet;
|
return _alphabet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a string of characters into an array of integer indices according to the configured alphabet.
|
||||||
|
*
|
||||||
|
* @param data the input string to transform; may not be null
|
||||||
|
* @return an integer array representing the indices of the characters in the given string
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public int[] transform(String data) {
|
public int[] transform(String data) {
|
||||||
return transformations.transform(data);
|
return transformations.transform(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms an array of integer indices back into a string using the configured alphabet.
|
||||||
|
*
|
||||||
|
* @param data the array of indices to transform back into characters
|
||||||
|
* @return the resulting string produced from the given index array
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public String transform(int[] data) {
|
public String transform(int[] data) {
|
||||||
return transformations.transform(data);
|
return transformations.transform(data);
|
||||||
|
|||||||
@@ -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`.
|
||||||
+24
-5
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
@@ -16,27 +16,46 @@
|
|||||||
<link.name>${project.artifactId}</link.name>
|
<link.name>${project.artifactId}</link.name>
|
||||||
<launcher>${project.artifactId}</launcher>
|
<launcher>${project.artifactId}</launcher>
|
||||||
<appName>${project.artifactId}</appName>
|
<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>
|
<jar.filename>${project.artifactId}-${project.version}</jar.filename>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<!-- FX dependency -->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>de.neitzel.lib</groupId>
|
<groupId>de.neitzel.lib</groupId>
|
||||||
<artifactId>fx</artifactId>
|
<artifactId>fx</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-simple</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<finalName>${jar.filename}</finalName>
|
<finalName>${jar.filename}</finalName>
|
||||||
<plugins>
|
<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>
|
<plugin>
|
||||||
<groupId>com.github.spotbugs</groupId>
|
<groupId>com.github.spotbugs</groupId>
|
||||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-pmd-plugin</artifactId>
|
<artifactId>maven-pmd-plugin</artifactId>
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
package de.neitzel.fx.component.example;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.Setter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public class Address {
|
|
||||||
private String street;
|
|
||||||
private String city;
|
|
||||||
}
|
|
||||||
@@ -1,36 +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;
|
|
||||||
|
|
||||||
public class ExampleApp extends 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
launch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +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 {
|
|
||||||
/**
|
|
||||||
* Additional main methode to start Application.
|
|
||||||
* @param args Commandline Arguments.
|
|
||||||
*/
|
|
||||||
public static void main(String[] args) {
|
|
||||||
ExampleApp.main(args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package de.neitzel.fx.component.example;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.Setter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public class Person {
|
|
||||||
private String name;
|
|
||||||
private Address address;
|
|
||||||
}
|
|
||||||
@@ -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,28 +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;
|
|
||||||
|
|
||||||
public class JavaFXApp extends Application {
|
|
||||||
|
|
||||||
@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) {
|
|
||||||
System.out.println("Exception: " + ex.getMessage());
|
|
||||||
ex.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
launch(args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +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 {
|
|
||||||
/**
|
|
||||||
* Additional main methode to start Application.
|
|
||||||
* @param args Commandline Arguments.
|
|
||||||
*/
|
|
||||||
public static void main(String[] args) {
|
|
||||||
JavaFXApp.main(args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +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;
|
|
||||||
|
|
||||||
public class MainWindow implements Initializable {
|
|
||||||
|
|
||||||
private int counter = 0;
|
|
||||||
|
|
||||||
@FXML
|
|
||||||
private TextField textField;
|
|
||||||
|
|
||||||
@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="http://javafx.com/javafx"
|
|
||||||
xmlns:fx="http://javafx.com/fxml"
|
|
||||||
xmlns:nfx="http://example.com/nfx"
|
|
||||||
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,11 +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 prefHeight="127.0" prefWidth="209.0" xmlns="http://javafx.com/javafx/17.0.2-ea" xmlns:fx="http://javafx.com/fxml/1" 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="http://javafx.com/javafx"
|
|
||||||
xmlns:fx="http://javafx.com/fxml"
|
|
||||||
xmlns:nfx="http://example.com/nfx"
|
|
||||||
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.
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
- SimpleListProperty auch nutzen bei Collections!
|
||||||
|
|
||||||
|
# Ideen
|
||||||
|
|
||||||
|
# Bindings
|
||||||
|
|
||||||
|
Bindngs kommen über ein spezielles Binding Control, das dann notwendige Bindings beinhaltet.
|
||||||
|
|
||||||
|
Da kann dann auch eine eigene Logik zur Erkennung des Bindings erfolgen oder zusätziche Informationen bezüglich
|
||||||
|
notwendiger Elemente in dem ViewModel
|
||||||
|
|
||||||
|
## Bidirektional Converter für Bindings
|
||||||
|
|
||||||
|
```Java
|
||||||
|
StringConverter<Instant> converter = new StringConverter<>() {
|
||||||
|
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString(Instant instant) {
|
||||||
|
return instant == null ? "" : formatter.format(instant);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Instant fromString(String string) {
|
||||||
|
if (string == null || string.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
LocalDateTime dateTime = LocalDateTime.parse(string, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
return dateTime.atZone(ZoneId.systemDefault()).toInstant();
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
// Optional: Fehlermeldung anzeigen
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Binding einrichten:
|
||||||
|
Bindings.
|
||||||
|
|
||||||
|
bindBidirectional(textField.textProperty(),instantProperty,converter);
|
||||||
|
```
|
||||||
|
|
||||||
|
Overloads von bindBidirectional
|
||||||
|
bindBidirectional(Property<String>, Property<?>, Format)
|
||||||
|
bindBidirectional(Property<String>, Property<T>, StringConverter<T>)
|
||||||
|
bindBidirectional(Property<T>, Property<T>)
|
||||||
|
|
||||||
|
## Unidirektional mit Bindings.createStringBinding()
|
||||||
|
|
||||||
|
```Java
|
||||||
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());
|
||||||
|
|
||||||
|
textField.
|
||||||
|
|
||||||
|
textProperty().
|
||||||
|
|
||||||
|
bind(Bindings.createStringBinding(
|
||||||
|
() ->{
|
||||||
|
|
||||||
|
Instant instant = instantProperty.get();
|
||||||
|
return instant ==null?"":formatter.
|
||||||
|
|
||||||
|
format(instant);
|
||||||
|
},
|
||||||
|
instantProperty
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
Bindings:
|
||||||
|
|
||||||
|
- BooleanBinding
|
||||||
|
- DoubleBinding
|
||||||
|
- FloatBinding
|
||||||
|
- IntegerBinding
|
||||||
|
- LongBinding
|
||||||
|
- StringBinding
|
||||||
|
- ObjectBinding
|
||||||
|
- NumberBinding
|
||||||
|
|
||||||
|
Bindings haben Methoden, die dann weitere Bindings erzeugen.
|
||||||
|
==> Parameter der Methode: Property, Observable und einer Methode, die ein Binding erstellt und das ViewModel<?>, das
|
||||||
|
dann genutzt werden kann, um weitere Properties zu holen ==> Erzeugung von neuen Properties.
|
||||||
|
|
||||||
|
Diese BindingCreators haben Namen, PropertyTyp und ein BindingType.
|
||||||
|
|
||||||
|
# FXMLComponent
|
||||||
|
|
||||||
|
Dient dem Laden einer Komponente und bekommt dazu das fxml, die Daten und ggf. auch eine ModelView.
|
||||||
|
|
||||||
|
# ModelView generieren
|
||||||
|
|
||||||
|
Damit eine ModelView nicht ständig manuell generiert werden muss, ist hier ggf. etwas zu generieren?
|
||||||
|
Ggf. eine eigenes Beschreibungssprache?
|
||||||
|
|
||||||
|
# Aufbau einer Validierung
|
||||||
|
|
||||||
|
- gehört in das ViewModel
|
||||||
|
-
|
||||||
+7
-2
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
@@ -49,6 +49,11 @@
|
|||||||
<artifactId>core</artifactId>
|
<artifactId>core</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>de.neitzel.lib</groupId>
|
||||||
|
<artifactId>injection</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
package de.neitzel.fx.component;
|
|
||||||
|
|
||||||
import javafx.beans.property.*;
|
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
|
||||||
import java.lang.reflect.Modifier;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class AutoViewModel<T> {
|
|
||||||
|
|
||||||
private final T model;
|
|
||||||
private final Map<String, Property<?>> properties = new HashMap<>();
|
|
||||||
|
|
||||||
public AutoViewModel(T model) {
|
|
||||||
this.model = model;
|
|
||||||
initProperties();
|
|
||||||
}
|
|
||||||
|
|
||||||
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) -> {
|
|
||||||
Method setter = findSetterFor(model.getClass(), fieldName, newVal != null ? newVal.getClass() : null);
|
|
||||||
if (setter != null) {
|
|
||||||
try {
|
|
||||||
setter.invoke(model, newVal);
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Property<?> getProperty(String name) {
|
|
||||||
return properties.get(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
public T getModel() {
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Hilfsmethoden ==========
|
|
||||||
|
|
||||||
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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String decapitalize(String str) {
|
|
||||||
if (str == null || str.isEmpty()) return str;
|
|
||||||
return str.substring(0, 1).toLowerCase() + str.substring(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object invokeGetter(Method method) {
|
|
||||||
try {
|
|
||||||
return method.invoke(model);
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String capitalize(String str) {
|
|
||||||
if (str == null || str.isEmpty()) return str;
|
|
||||||
return str.substring(0, 1).toUpperCase() + str.substring(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package de.neitzel.fx.component;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
package de.neitzel.fx.component;
|
|
||||||
|
|
||||||
import javafx.beans.property.Property;
|
|
||||||
import javafx.fxml.FXMLLoader;
|
|
||||||
import javafx.scene.Parent;
|
|
||||||
import javafx.scene.layout.Pane;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ComponentLoader is responsible for loading JavaFX FXML components and binding
|
|
||||||
* them to automatically generated ViewModels based on simple POJO models.
|
|
||||||
* <p>
|
|
||||||
* It parses custom NFX attributes in FXML to bind UI elements to properties in the ViewModel,
|
|
||||||
* and supports recursive loading of subcomponents.
|
|
||||||
*/
|
|
||||||
public class ComponentLoader {
|
|
||||||
private Map<String, Map<String, String>> nfxBindingMap = new HashMap<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
|
||||||
try {
|
|
||||||
AutoViewModel<?> viewModel = new AutoViewModel<>(model);
|
|
||||||
String cleanedUri = preprocessNfxAttributes(fxmlPath);
|
|
||||||
FXMLLoader loader = new FXMLLoader(new URL(cleanedUri));
|
|
||||||
loader.setControllerFactory(type -> new ComponentController(viewModel));
|
|
||||||
Parent root = loader.load();
|
|
||||||
processNfxBindings(root, viewModel, loader);
|
|
||||||
return root;
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException("unable to load fxml: " + fxmlPath, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes all UI elements for NFX binding attributes and applies
|
|
||||||
* the appropriate bindings to the ViewModel.
|
|
||||||
*
|
|
||||||
* @param root the root node of the loaded FXML hierarchy
|
|
||||||
* @param viewModel the generated ViewModel to bind against
|
|
||||||
*/
|
|
||||||
private void processNfxBindings(Parent root, AutoViewModel<?> viewModel, FXMLLoader loader) {
|
|
||||||
walkNodes(root, node -> {
|
|
||||||
var nfx = lookupNfxAttributes(node, loader);
|
|
||||||
String target = nfx.get("nfx:target");
|
|
||||||
String direction = nfx.get("nfx:direction");
|
|
||||||
String source = nfx.get("nfx:source");
|
|
||||||
|
|
||||||
if (target != null && direction != null) {
|
|
||||||
Property<?> vmProp = viewModel.getProperty(target);
|
|
||||||
bindNodeToProperty(node, vmProp, direction);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source != null) {
|
|
||||||
Object subModel = ((Property<?>) viewModel.getProperty(target)).getValue();
|
|
||||||
Parent subComponent = load(subModel, source);
|
|
||||||
if (node instanceof Pane pane) {
|
|
||||||
pane.getChildren().setAll(subComponent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively walks all nodes in the scene graph starting from the root,
|
|
||||||
* applying the given consumer to each node.
|
|
||||||
*
|
|
||||||
* @param root the starting node
|
|
||||||
* @param consumer the consumer to apply to each node
|
|
||||||
*/
|
|
||||||
private void walkNodes(Parent root, java.util.function.Consumer<javafx.scene.Node> consumer) {
|
|
||||||
consumer.accept(root);
|
|
||||||
if (root instanceof Pane pane) {
|
|
||||||
for (javafx.scene.Node child : pane.getChildren()) {
|
|
||||||
if (child instanceof Parent p) {
|
|
||||||
walkNodes(p, consumer);
|
|
||||||
} else {
|
|
||||||
consumer.accept(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts custom NFX attributes from a node's properties map.
|
|
||||||
* These attributes are expected to be in the format "nfx:..." and hold string values.
|
|
||||||
*
|
|
||||||
* @param node the node to inspect
|
|
||||||
* @return a map of NFX attribute names to values
|
|
||||||
*/
|
|
||||||
private Map<String, String> extractNfxAttributes(javafx.scene.Node node) {
|
|
||||||
Map<String, String> result = new HashMap<>();
|
|
||||||
node.getProperties().forEach((k, v) -> {
|
|
||||||
if (k instanceof String key && key.startsWith("nfx:") && v instanceof String value) {
|
|
||||||
result.put(key, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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")
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
private String preprocessNfxAttributes(String fxmlPath) {
|
|
||||||
try {
|
|
||||||
nfxBindingMap.clear();
|
|
||||||
var factory = javax.xml.parsers.DocumentBuilderFactory.newInstance();
|
|
||||||
var builder = factory.newDocumentBuilder();
|
|
||||||
var doc = builder.parse(getClass().getResourceAsStream(fxmlPath));
|
|
||||||
var all = doc.getElementsByTagName("*");
|
|
||||||
|
|
||||||
int autoId = 0;
|
|
||||||
for (int i = 0; i < all.getLength(); i++) {
|
|
||||||
var el = (org.w3c.dom.Element) all.item(i);
|
|
||||||
Map<String, String> nfxAttrs = new HashMap<>();
|
|
||||||
var attrs = el.getAttributes();
|
|
||||||
|
|
||||||
List<String> toRemove = new ArrayList<>();
|
|
||||||
for (int j = 0; j < attrs.getLength(); j++) {
|
|
||||||
var attr = (org.w3c.dom.Attr) attrs.item(j);
|
|
||||||
if (attr.getName().startsWith("nfx:")) {
|
|
||||||
nfxAttrs.put(attr.getName(), attr.getValue());
|
|
||||||
toRemove.add(attr.getName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nfxAttrs.isEmpty()) {
|
|
||||||
String fxid = el.getAttribute("fx:id");
|
|
||||||
if (fxid == null || fxid.isBlank()) {
|
|
||||||
fxid = "auto_id_" + (++autoId);
|
|
||||||
el.setAttribute("fx:id", fxid);
|
|
||||||
}
|
|
||||||
nfxBindingMap.put(fxid, nfxAttrs);
|
|
||||||
toRemove.forEach(el::removeAttribute);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Speichere das bereinigte Dokument als temporäre Datei
|
|
||||||
File tempFile = File.createTempFile("cleaned_fxml", ".fxml");
|
|
||||||
tempFile.deleteOnExit();
|
|
||||||
var transformer = javax.xml.transform.TransformerFactory.newInstance().newTransformer();
|
|
||||||
transformer.setOutputProperty(javax.xml.transform.OutputKeys.INDENT, "yes");
|
|
||||||
transformer.transform(new javax.xml.transform.dom.DOMSource(doc),
|
|
||||||
new javax.xml.transform.stream.StreamResult(tempFile));
|
|
||||||
|
|
||||||
return tempFile.toURI().toString();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new RuntimeException("Preprocessing failed for: " + fxmlPath, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, String> lookupNfxAttributes(javafx.scene.Node node, FXMLLoader loader) {
|
|
||||||
String fxid = loader.getNamespace().entrySet().stream()
|
|
||||||
.filter(e -> e.getValue() == node)
|
|
||||||
.map(Map.Entry::getKey)
|
|
||||||
.findFirst().orElse(null);
|
|
||||||
if (fxid == null) return Map.of();
|
|
||||||
return nfxBindingMap.getOrDefault(fxid, Map.of());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package de.neitzel.fx.injectfx;
|
|
||||||
|
|
||||||
import de.neitzel.core.inject.ComponentData;
|
|
||||||
import de.neitzel.core.inject.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,93 +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.
|
|
||||||
*
|
|
||||||
* This class simplifies the process of injecting dependencies into JavaFX controllers by analyzing
|
|
||||||
* the constructors of the given controller classes at runtime. It selects the constructor that best
|
|
||||||
* matches the available dependencies in the parameter map and creates an instance of the controller.
|
|
||||||
*
|
|
||||||
* It implements the Callback interface to provide compatibility with the JavaFX FXMLLoader, allowing
|
|
||||||
* controllers with dependencies to be injected seamlessly during the FXML loading process.
|
|
||||||
*/
|
|
||||||
public class InjectingControllerFactory implements Callback<Class<?>, Object> {
|
|
||||||
/**
|
|
||||||
* A map that stores class-to-object mappings used for dependency injection
|
|
||||||
* in controller instantiation. This map is utilized to resolve and supply
|
|
||||||
* the required dependencies for constructors during the creation of controller
|
|
||||||
* instances.
|
|
||||||
*
|
|
||||||
* Each key in the map represents a class type, and the corresponding value
|
|
||||||
* is the instance of that type. This allows the {@link InjectingControllerFactory}
|
|
||||||
* to use the stored instances to dynamically match and inject dependencies
|
|
||||||
* into controllers at runtime.
|
|
||||||
*/
|
|
||||||
private final Map<Class<?>, Object> parameterMap = new HashMap<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.core.inject.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.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* 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}.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* 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,116 +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 lombok.RequiredArgsConstructor;
|
|
||||||
|
|
||||||
import java.io.InputStream;
|
|
||||||
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
|
|
||||||
if (node instanceof TextInputControl control) {
|
|
||||||
Property<String> prop = viewModel.property(StringProperty.class, propertyName);
|
|
||||||
bind(control.textProperty(), prop, direction);
|
|
||||||
} else if (node instanceof javafx.scene.control.Label label) {
|
|
||||||
Property<String> prop = viewModel.property(StringProperty.class, propertyName);
|
|
||||||
bind(label.textProperty(), prop, direction);
|
|
||||||
} else if (node instanceof javafx.scene.control.CheckBox checkBox) {
|
|
||||||
Property<Boolean> prop = viewModel.property(javafx.beans.property.BooleanProperty.class, propertyName);
|
|
||||||
bind(checkBox.selectedProperty(), prop, direction);
|
|
||||||
} else if (node instanceof javafx.scene.control.Slider slider) {
|
|
||||||
Property<Number> prop = viewModel.property(javafx.beans.property.DoubleProperty.class, propertyName);
|
|
||||||
bind(slider.valueProperty(), prop, direction);
|
|
||||||
} else if (node instanceof 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,57 +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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the original model instance from the ViewModel.
|
|
||||||
*
|
|
||||||
* @return the model instance
|
|
||||||
*/
|
|
||||||
public T getModel() {
|
|
||||||
return viewModel.getModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,144 +0,0 @@
|
|||||||
package de.neitzel.fx.mvvm;
|
|
||||||
|
|
||||||
import javafx.beans.property.*;
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user