Document FX and injection and integrate the Designer reference example

This commit is contained in:
2026-11-13 23:04:20 +01:00
parent 90e08b7835
commit 922ede8608
25 changed files with 876 additions and 50 deletions
+3
View File
@@ -5,3 +5,6 @@ debug.out
/nbactions.xml
dependency-reduced-pom.xml
.DS_Store
# Machine-local designer paths, snapshots and drafts
.neitzel-fx-designer/
+8 -2
View File
@@ -51,7 +51,7 @@ Package scanner and type-based container used by `fx`. See [injection/README.md]
### fx-example
Person desk that shows how to use `fx` and `injection`. Run `mvn -pl fx-example -am javafx:run`. See [fx-example/README.md](fx-example/README.md).
Person desk that shows how to use `fx` and `injection`. Build with `./mvnw -pl fx-example -am install`, then run `./mvnw -f fx-example/pom.xml javafx:run`. See [fx-example/README.md](fx-example/README.md).
### gson
@@ -68,4 +68,10 @@ Some network stuff using javax.mail as dependency.
### quarkus
Some additions for quarkus that might be useful.
Some additions for quarkus that might be useful.
## Start a JavaFX project
The [complete quick start](fx/docs/quick-start.md) includes local snapshot installation, a standalone Maven POM, Java classes, FXML and launch commands. Continue with [MVVM and runtime architecture](fx/docs/architecture.md), the [FXML reference](fx/README.md), and the [person desk walkthrough](fx-example/README.md). The [injection guide](injection/README.md) also covers use without JavaFX.
Build with Java 21. Maven wrapper version is 3.9.11; the project requires Maven 3.8.6 or later. Library version `1.0-SNAPSHOT` denotes development artifacts, not a published stable release. The parent POM currently ignores Surefire failures: check test reports, as explained in the quick start, rather than relying only on Maven's exit code.
+78 -6
View File
@@ -1,13 +1,85 @@
# fx-example
# Person desk reference application
A person desk that uses [`fx`](../fx/README.md) and [`injection`](../injection/README.md). The guides use this module as the worked example.
This runnable application demonstrates [NeitzelLib FX](../fx/README.md) and [injection](../injection/README.md) together. It edits one Person with a home Address, nested Note, visit list, selected visit and tags. Use it as a reference for wiring a real screen; the [minimal quick start](../fx/docs/quick-start.md) is the smaller starting point for a new project.
The desk shows a direct binding in each direction, a converter, a computed binding, a view model subclass, a component with a nested note, a repeat, a table with a starting width and an open column, a date column, a list of simple values, and messages. The container side shows `@Config`, `@Singleton`, `@Named`, and injection by constructor, field, and setter.
## Build and run
From the NeitzelLib root, with Java 21:
From the NeitzelLib root with JDK 21:
```bash
mvn -pl fx-example -am javafx:run
./mvnw -pl fx-example -am install
./mvnw -f fx-example/pom.xml javafx:run
```
The launch class is `de.neitzel.fx.example.Main`. The model is an ordinary `Person`. The view is `Desk.fxml`. `DeskLoadsTest` loads that same desk.
The first command installs local `1.0-SNAPSHOT` artifacts; the second runs only the application module. Do not combine `javafx:run` with `-am`, since the parent has no JavaFX launcher. The repository currently ignores Surefire failures; verify XML reports as shown in the [quick start](../fx/docs/quick-start.md), even when Maven reports success.
The packaged alternative, after `package` or `install`, is:
```bash
java -jar fx-example/target/fx-example-1.0-SNAPSHOT.jar
```
Keep the adjacent `target/libs` directory with the JAR: the manifest refers to those copied runtime dependencies. A desktop session is required. This example has no `module-info.java`; it is a classpath application.
## Follow the code
Paths below are relative to this module. Java sources share `src/main/java/de/neitzel/fx/example/`; FXML resources share `src/main/resources/de/neitzel/fx/example/view/`.
| File/type | Responsibility |
| --- | --- |
| `Main` | Plain Java launcher calling `Application.launch` |
| `DeskApp` | Scan configuration, fresh sample Model, ViewLoader, Scene and message popup registration |
| `model/Person`, `Address`, `Note`, `Role` | JavaBean data and enum; no JavaFX properties required |
| `view/DeskController` and `Desk.fxml` | Screen handlers, computed greeting, controls and binding declarations |
| `view/PersonViewModel` | Handwritten ViewModel subclass with a derived title |
| `view/AddressForm` and `AddressForm.fxml` | Reusable home subform and injected heading, containing NoteForm |
| `view/NoteForm` and `NoteForm.fxml` | Nested Component bound to the note |
| `view/VisitForm` and `VisitForm.fxml` | Fresh form per repeated Address |
| `view/VisitRow` and `VisitRow.fxml` | Table row with editable fields and a trailing Remove button |
| `src/test/resources/de/neitzel/fx/example/NameLine.fxml` | Test-only second View of the same Person without reusable Component nodes |
| `view/AgeConverter`, `StreetConverter`, `VisitedOnConverter` | Text adaptation, validation failures and ISO dates |
Use the actual source directories to locate helper services such as `AgeSuffix`, `StreetPolicy` and `FormHeading`. The injection guide explains constructor, field and setter injection with these types. No code generation is required to run this example; `PersonViewModel` is handwritten.
## Try the behaviors
1. Change Name. The Model and name echo follow; title and computed greeting refresh. The draft field uses `direction="model"`: its edits write to the Model but later changes from another field do not overwrite it.
2. Edit Age using its `years ` text format. An invalid value stays visible while the previous Model value is retained. A message explains the failure; a valid value clears that conversion failure.
3. Edit the home street. The path-bound label follows. Empty street input fails through `StreetConverter`; the parent receives the child message with the `home.street` source prefix.
4. Edit the nested note. AddressForm's inner NoteForm binds to its own Model. The `Opened as ...` label records initial setup rather than tracking every later city edit.
5. Add a visit. Both the repeated forms and Table gain a row. Edit a street in one representation and observe the same Address in the other. The Table's Remove button delegates to the controller.
6. Select a visit in the ComboBox or Table. Both use the `selectedVisit` Attribute. Resize, sort and filter Table columns; filtering and sorting affect displayed rows without reordering the Model's list.
7. Edit the visited date in ISO form (`yyyy-MM-dd`). Invalid text keeps the old date. Add a tag to see a shared ObservableList of simple values.
8. Try the information, warning and fatal message buttons. Severity is data and logging behavior; a fatal message does not itself exit the application. Close each non-blocking popup normally.
Changing a JavaBean setter directly after loading does not notify controls. All edits above go through ViewModel properties or observable collections. The [architecture guide](../fx/docs/architecture.md) explains this distinction and why you should normally use a new context for a separate window.
## Automated regression coverage
`src/test/java/de/neitzel/fx/example/DeskLoadsTest.java` loads the real Desk FXML on JavaFX and checks initial values, all binding directions, derived/computed text, conversion failures and recovery, child messages, Table edits/removal, message severities and boolean/enum write-back. It also demonstrates that a direct bean setter leaves controls unchanged and that another simple View reuses the same Model's ViewModel.
Run the reactor tests without installing artifacts with:
```bash
./mvnw -pl fx-example -am clean verify
```
Inspect Surefire XML reports; a successful Maven exit alone is insufficient with the current parent configuration. JavaFX tests need a usable graphical environment. See the Designer integration section below for the optional preview setup; ordinary application tests do not require the Designer.
## Open the example in NeitzelFXDesigner
The repository root includes `neitzel-fx-designer.json` with the `person-desk` context. It selects `fx-example`, classpath launch, the `designer-preview` Maven profile, the local runtime key `java21`, and `de.neitzel.fx.example.preview.DeskPreviewProvider`. Configure your own JDK path for that key; machine paths are not part of the shared project configuration.
The optional profile adds the Designer preview API only to the test source set, compiles `src/designer/java`, includes its resources, and produces Model metadata for Person, Address and Note. It does not add a runtime dependency to the ordinary application, replace its handwritten PersonViewModel, or generate a default ViewModel registry.
After preparing the Designer's local preview API and metadata processor artifacts, build with:
```bash
./mvnw -pl fx-example -am -Pdesigner-preview verify
```
Check XML test reports as for the normal build. Follow `docs/user-guide/quick-start.md` in the [NeitzelFXDesigner repository](https://gitea.neitzel.de/konrad/NeitzelFXDesigner) for the complete artifact preparation, library-example setup script, local runtime mapping, application launch and Build and Refresh workflow. That guide covers both the standalone host and IntelliJ integration; this profile alone is not a Designer installation.
FXML documents carry `neitzel-model` processing instructions for discovery. The preview provider offers `desk`, `address`, `visit`, `row` and `note` samples for the corresponding documents. Every session receives fresh sample Models and an injection context; the preview does not install the application's message popups. Open the document, Build and Refresh, choose its matching sample, then start interactive preview. Editing the preview's data does not save those values into the application source.
The role choices are populated in `DeskController.initialize()` rather than using an application-enum `fx:constant` in FXML, because the current Designer projection cannot resolve that application constant. This is a Designer limitation, not a restriction of NeitzelLib or ordinary FXMLLoader. The regular application still shows the same Role values.
+50
View File
@@ -97,4 +97,54 @@
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>designer-preview</id>
<dependencies>
<dependency>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>designer-preview-api</artifactId>
<version>0.1.0-SNAPSHOT</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<id>designer-preview-sources</id>
<phase>generate-test-sources</phase>
<goals><goal>add-test-source</goal><goal>add-test-resource</goal></goals>
<configuration>
<sources><source>src/designer/java</source></sources>
<resources><resource><directory>src/designer/resources</directory></resource></resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<proc>full</proc>
<annotationProcessorPaths combine.self="override">
<path>
<groupId>de.neitzel.fxdesigner</groupId>
<artifactId>designer-model-processor</artifactId>
<version>0.1.0-SNAPSHOT</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-Adesigner.models=de.neitzel.fx.example.model.Person,de.neitzel.fx.example.model.Address,de.neitzel.fx.example.model.Note</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
@@ -0,0 +1,31 @@
package de.neitzel.fx.example.preview;
import de.neitzel.fx.example.DeskApp;
import de.neitzel.fx.example.model.Address;
import de.neitzel.fx.example.view.DeskController;
import de.neitzel.fx.example.view.PersonViewModel;
import de.neitzel.fxdesigner.preview.PreviewProvider;
import de.neitzel.fxdesigner.preview.PreviewSession;
import de.neitzel.injection.ApplicationContext;
/** Independent samples for the nested, repeated and table-row Address components. */
public final class AddressPreviewProvider implements PreviewProvider<Address, ApplicationContext> {
@Override
public Class<Address> modelType() {
return Address.class;
}
@Override
public PreviewSession<Address, ApplicationContext> open(String id) {
if (!java.util.Set.of("address", "visit", "row").contains(id))
throw new IllegalArgumentException("Unknown address example: " + id);
var person = DeskApp.sample();
var context = new ApplicationContext(DeskApp.class);
// An independently previewed row still needs the parent action's state.
// No Desk.fxml is loaded and no popup listener is installed.
if ("row".equals(id))
context.getComponent(DeskController.class).setViewModel(new PersonViewModel(person));
return new SampleSession<>(
"address".equals(id) ? person.getHome() : person.getVisits().getFirst(), context);
}
}
@@ -0,0 +1,21 @@
package de.neitzel.fx.example.preview;
import de.neitzel.fx.example.DeskApp;
import de.neitzel.fx.example.model.Person;
import de.neitzel.fxdesigner.preview.PreviewProvider;
import de.neitzel.fxdesigner.preview.PreviewSession;
import de.neitzel.injection.ApplicationContext;
/** Optional test-source preview adapter; it never enters the application distribution. */
public final class DeskPreviewProvider implements PreviewProvider<Person, ApplicationContext> {
@Override
public Class<Person> modelType() {
return Person.class;
}
@Override
public PreviewSession<Person, ApplicationContext> open(String id) {
if (!"desk".equals(id)) throw new IllegalArgumentException("Unknown desk example: " + id);
return new SampleSession<>(DeskApp.sample(), new ApplicationContext(DeskApp.class));
}
}
@@ -0,0 +1,22 @@
package de.neitzel.fx.example.preview;
import de.neitzel.fx.example.DeskApp;
import de.neitzel.fx.example.model.Note;
import de.neitzel.fxdesigner.preview.PreviewProvider;
import de.neitzel.fxdesigner.preview.PreviewSession;
import de.neitzel.injection.ApplicationContext;
/** Independent sample for the nested note editor. */
public final class NotePreviewProvider implements PreviewProvider<Note, ApplicationContext> {
@Override
public Class<Note> modelType() {
return Note.class;
}
@Override
public PreviewSession<Note, ApplicationContext> open(String id) {
if (!"note".equals(id)) throw new IllegalArgumentException("Unknown note example: " + id);
return new SampleSession<>(
DeskApp.sample().getHome().getNote(), new ApplicationContext(DeskApp.class));
}
}
@@ -0,0 +1,86 @@
package de.neitzel.fx.example.preview;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fx.example.view.DeskController;
import de.neitzel.fx.view.ViewLoader;
import javafx.application.Platform;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.concurrent.*;
class PreviewProvidersTest {
@BeforeAll
static void startToolkit() throws Exception {
var ready = new CountDownLatch(1);
try {
Platform.startup(ready::countDown);
} catch (IllegalStateException alreadyRunning) {
ready.countDown();
}
assertTrue(ready.await(10, TimeUnit.SECONDS));
}
private static void onFx(Runnable action) throws Exception {
var result = new CompletableFuture<Void>();
Platform.runLater(
() -> {
try {
action.run();
result.complete(null);
} catch (Throwable e) {
result.completeExceptionally(e);
}
});
result.get(20, TimeUnit.SECONDS);
}
@Test
void deskSamplesAreFreshAndLoadTheRealDesk() throws Exception {
onFx(
() -> {
var provider = new DeskPreviewProvider();
var first = provider.open("desk");
var second = provider.open("desk");
assertNotSame(first.model(), second.model());
assertNotSame(first.model().getHome(), second.model().getHome());
assertNotSame(first.context(), second.context());
var view =
ViewLoader.load(
DeskController.class.getResource("Desk.fxml"),
first.model(),
first.context());
assertNotNull(view.getRoot().lookup("#nameField"));
assertEquals("Ada Lovelace", first.model().getName());
assertTrue(first.ready().toCompletableFuture().isDone());
assertThrows(IllegalArgumentException.class, () -> provider.open("unknown"));
});
}
@Test
void componentSamplesAndIndependentRowActionHaveRequiredState() throws Exception {
onFx(
() -> {
var addresses = new AddressPreviewProvider();
var home = addresses.open("address");
assertNotNull(home.model().getNote());
var visit = addresses.open("visit");
assertNotNull(visit.model().getVisitedOn());
var row = addresses.open("row");
assertDoesNotThrow(
() ->
row.context()
.getComponent(DeskController.class)
.removeVisit(row.model()));
var note = new NotePreviewProvider().open("note");
assertEquals("Kept at home", note.model().getText());
assertThrows(IllegalArgumentException.class, () -> addresses.open("desk"));
assertThrows(
IllegalArgumentException.class,
() -> new NotePreviewProvider().open("desk"));
});
}
}
@@ -0,0 +1,11 @@
package de.neitzel.fx.example.preview;
import de.neitzel.fxdesigner.preview.PreviewSession;
import de.neitzel.injection.ApplicationContext;
/** The sample owns no timers, network connections or background work to release. */
record SampleSession<M>(M model, ApplicationContext context)
implements PreviewSession<M, ApplicationContext> {
@Override
public void close() {}
}
@@ -0,0 +1,37 @@
format=1
count=5
example.0.id=desk
example.0.label=Person desk
example.0.modelType=de.neitzel.fx.example.model.Person
example.0.providerClass=de.neitzel.fx.example.preview.DeskPreviewProvider
example.0.viewResource=de/neitzel/fx/example/view/Desk.fxml
example.0.componentPackage=de.neitzel.fx.example.view
example.1.id=address
example.1.label=Home address
example.1.modelType=de.neitzel.fx.example.model.Address
example.1.providerClass=de.neitzel.fx.example.preview.AddressPreviewProvider
example.1.viewResource=de/neitzel/fx/example/view/AddressForm.fxml
example.1.componentPackage=de.neitzel.fx.example.view
example.2.id=visit
example.2.label=Repeated visit
example.2.modelType=de.neitzel.fx.example.model.Address
example.2.providerClass=de.neitzel.fx.example.preview.AddressPreviewProvider
example.2.viewResource=de/neitzel/fx/example/view/VisitForm.fxml
example.2.componentPackage=de.neitzel.fx.example.view
example.3.id=row
example.3.label=Table row
example.3.modelType=de.neitzel.fx.example.model.Address
example.3.providerClass=de.neitzel.fx.example.preview.AddressPreviewProvider
example.3.viewResource=de/neitzel/fx/example/view/VisitRow.fxml
example.3.componentPackage=de.neitzel.fx.example.view
example.4.id=note
example.4.label=Address note
example.4.modelType=de.neitzel.fx.example.model.Note
example.4.providerClass=de.neitzel.fx.example.preview.NotePreviewProvider
example.4.viewResource=de/neitzel/fx/example/view/NoteForm.fxml
example.4.componentPackage=de.neitzel.fx.example.view
@@ -1,6 +1,7 @@
package de.neitzel.fx.example.view;
import de.neitzel.fx.example.model.Address;
import de.neitzel.fx.example.model.Role;
import de.neitzel.fx.view.HasViewModel;
import de.neitzel.fx.view.Message;
import de.neitzel.fx.view.ViewModel;
@@ -8,6 +9,7 @@ import de.neitzel.fx.view.ViewModelClass;
import jakarta.inject.Singleton;
import javafx.fxml.FXML;
import javafx.scene.control.TextField;
import javafx.scene.control.ChoiceBox;
import java.time.LocalDate;
import java.util.List;
@@ -35,6 +37,16 @@ public class DeskController implements HasViewModel {
@FXML
private TextField tagField;
/** Role values are supplied in the application runtime, not instantiated by the designer host. */
@FXML
private ChoiceBox<Role> roleChoice;
/** Supplies enum choices before the declarative value binding is applied. */
@FXML
public void initialize() {
roleChoice.getItems().setAll(Role.values());
}
/**
* The person view model, set before the bindings run.
*/
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<?neitzel-model de.neitzel.fx.example.model.Address?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Label?>
@@ -9,7 +10,7 @@
<?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">
xmlns:fx="http://javafx.com/fxml/1" spacing="6">
<padding>
<Insets top="4" right="4" bottom="4" left="4"/>
</padding>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<?neitzel-model de.neitzel.fx.example.model.Person?>
<?import javafx.collections.FXCollections?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.CheckBox?>
@@ -13,7 +13,6 @@
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.example.model.Role?>
<?import de.neitzel.fx.example.view.AddressForm?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.Column?>
@@ -22,7 +21,7 @@
<?import de.neitzel.fx.view.Repeat?>
<?import de.neitzel.fx.view.Table?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml"
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml/1"
fx:controller="de.neitzel.fx.example.view.DeskController"
spacing="8" prefWidth="520">
<padding>
@@ -70,14 +69,7 @@
<Label text="Title" GridPane.rowIndex="5" GridPane.columnIndex="0"/>
<Label fx:id="titleLabel" id="titleLabel" GridPane.rowIndex="5" GridPane.columnIndex="1"/>
<Label text="Role" GridPane.rowIndex="6" GridPane.columnIndex="0"/>
<ChoiceBox fx:id="roleChoice" id="roleChoice" GridPane.rowIndex="6" GridPane.columnIndex="1">
<items>
<FXCollections fx:factory="observableArrayList">
<Role fx:constant="READER"/>
<Role fx:constant="AUTHOR"/>
</FXCollections>
</items>
</ChoiceBox>
<ChoiceBox fx:id="roleChoice" id="roleChoice" GridPane.rowIndex="6" GridPane.columnIndex="1"/>
<Label text="Home street" GridPane.rowIndex="7" GridPane.columnIndex="0"/>
<Label fx:id="homeStreetLabel" id="homeStreetLabel" GridPane.rowIndex="7" GridPane.columnIndex="1"/>
</GridPane>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<?neitzel-model de.neitzel.fx.example.model.Note?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
@@ -7,7 +8,7 @@
<?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">
xmlns:fx="http://javafx.com/fxml/1" spacing="6">
<fx:define>
<BindingList>
<DirectBinding control="noteField" property="text" path="text" direction="both"/>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<?neitzel-model de.neitzel.fx.example.model.Address?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Label?>
@@ -8,7 +9,7 @@
<?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">
xmlns:fx="http://javafx.com/fxml/1" spacing="6">
<padding>
<Insets top="4" right="4" bottom="4" left="4"/>
</padding>
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<?neitzel-model de.neitzel.fx.example.model.Address?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextField?>
@@ -7,7 +8,7 @@
<?import de.neitzel.fx.view.DirectBinding?>
<fx:root type="javafx.scene.layout.HBox" xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml" spacing="8">
xmlns:fx="http://javafx.com/fxml/1" spacing="8">
<fx:define>
<BindingList>
<DirectBinding control="streetField" property="text" path="street" direction="both"/>
+70 -20
View File
@@ -1,5 +1,15 @@
# Using fx
## Documentation map
- [Build a complete application from scratch](docs/quick-start.md).
- [Architecture, MVVM, identity, threading and lifetime](docs/architecture.md).
- [Walk through the person desk](../fx-example/README.md).
- This page is the detailed FXML and runtime reference.
- [Generated ViewModel classes and registries](docs/generated-viewmodels.md).
- [Bounded integer spinners](docs/integer-spinners.md) and [system tray support](docs/tray-support.md).
- [Dependency injection](../injection/README.md).
## Introduction
`fx` binds an FXML view to an ordinary Java model. The model stays a JavaBean. The library builds one view model for that instance, loads the view, and applies the binding elements declared in the FXML.
@@ -9,12 +19,32 @@ You write the model, the FXML, and, where a screen needs them, a controller, reu
The worked example is the `fx-example` module, package `de.neitzel.fx.example`. From the NeitzelLib root, with Java 21:
```bash
mvn -pl fx-example -am javafx:run
./mvnw -pl fx-example -am -Dmaven.test.failure.ignore=false install
./mvnw -f fx-example/pom.xml javafx:run
```
The launch class is `de.neitzel.fx.example.Main`. `Main` does not extend `Application`. It calls `Application.launch(DeskApp.class, args)`, and `DeskApp.start` builds the scene on the JavaFX thread.
Depend on `de.neitzel.lib:fx`. That module depends on `injection` and on JavaFX base, controls, graphics, fxml, and web. Application types the container constructs use `jakarta.inject.Singleton` or `jakarta.inject.Named`.
Depend on `de.neitzel.lib:fx`. That module depends on `core`, `injection` and on JavaFX base, controls, graphics, fxml, and web. Application types the container constructs use `jakarta.inject.Singleton` or `jakarta.inject.Named`.
## Dependencies
These are the versions configured by this source snapshot, not a promise of compatibility with every newer release:
| Part | Baseline / relationship | Consumer responsibility |
| --- | --- | --- |
| JDK | Java 21 | Select a matching JDK for compilation and launch |
| Maven | Minimum 3.8.6; wrapper 3.9.11 | Use the wrapper or an installed Maven |
| Library | `de.neitzel.lib:fx:1.0-SNAPSHOT` | Install the local reactor first; no Central publication is assumed |
| Internal modules | `fx` directly depends on `core` and `injection` | Resolved transitively through Maven |
| JavaFX | 21.0.7: base, graphics, controls, FXML and web | Resolved transitively; deployment still needs platform-native artifacts |
| Injection scanning | Reflections 0.10.2 | Brought by `injection` |
| Injection annotations | `jakarta.inject-api` 2.0.1 | Use Jakarta annotations, not `javax.inject` |
| Logging | SLF4J API 2.0.17 | Choose one logging backend; the example uses `slf4j-simple` 2.0.17 at runtime |
| Library build | Lombok 1.18.42 and test/analysis tooling | Not required in ordinary consumer source; use your own annotations only if wanted |
| Designer | Optional preview API / Model processor | Needed only for the selected Designer workflow, not normal `ViewLoader` use |
The quick-start POM adds only `fx` and a logging backend. Do not copy library build-time dependencies into an application just to obtain runtime binding. A project using injection alone needs the `injection` artifact and does not need JavaFX.
## Quick start
@@ -135,9 +165,9 @@ A binding that names no such control fails with `No control "` and the id. A mis
The desk puts all three on `name`. The name field is `both`. Shown name (`nameEcho`) is `view`: typing there leaves `Person.name` and the name field unchanged, and typing in the name field updates the echo. Name draft (`nameDraft`) is `model`: it opens as `Ada Lovelace`, stays there while the name field changes, and typing in the draft writes `Person.name` and updates the name field and the echo.
A direction other than `view`, `model`, or `both` fails the load with `Not a direction:`.
Always specify `direction`: it has no default. A non-null value other than `view`, `model`, or `both` fails the load with `Not a direction:`. Omitting it can currently fail with a `NullPointerException` during binding application. The ObservableList shortcut described above bypasses direction handling; do not rely on that shortcut to omit the declaration.
For a complex last segment the control receives the model instance behind the view model, and the same three directions apply to later changes of that attribute. The home form's `model` property is `home` in both directions, so the form edits that address and a later replacement of `home` would follow. The desk sets the home address once.
For a complex last segment the control receives the model instance behind the view model, and the same three directions apply to later changes of that attribute. The home form's `model` property is `home` in both directions, so the form edits that address. A later change of the ViewModel's `home` property updates the Component's Model property, but its internal bindings do not reattach; create a new Component to edit another Model. The desk sets the home address once.
## Paths
@@ -145,7 +175,7 @@ A path is attribute names separated by `.`. One name is an attribute of this vie
`home.street` on the home street label steps through the home view model, and `view` applies to `street`. When a complex attribute along the path is null, that binding is left unapplied and both sides keep their current values. The load still succeeds.
The path is resolved when the binding is applied. A binding whose last segment is complex follows later changes of that attribute. A longer path stays with the child view model that was in place when the binding was applied. The home street label is `home.street`. Replacing `person.home` with another address later would leave that label on the street it was bound to.
The path is resolved when the binding is applied. A binding whose last segment is complex follows later changes of that attribute. A longer path stays with the child view model that was in place when the binding was applied. The home street label is `home.street`. Replacing the ViewModel's `home` property later would leave that label on the street it was bound to. Calling the bean setter directly would not notify either binding.
A list is not indexed in a path. Repeat a component, place a table, or hand a list of simple values to a control.
@@ -170,7 +200,7 @@ The age field is the desk's `both` converter. `AgeConverter` takes `AgeSuffix` i
A converter is not applied to a complex attribute or to an `ObservableList`. Those bindings assign the model instance or the list.
When a conversion throws, the other side keeps its previous value. The control keeps the text that was typed, because the failed write does not replace it. The view model that owns the last path segment records one error message. The source is that segment's name. The text is the exception message, or `Conversion failed` when the exception message is null or empty. A later failure of that same binding updates the message in place. A later success removes it.
When a conversion throws, the other side keeps its previous value. The control keeps the text that was typed, because the failed write does not replace it. The view model that owns the last path segment records one error message. The source is that segment's name. The text is the exception message, or `Conversion failed` when the exception message is null or empty. A later failure for that same Attribute updates the message in place. A later success removes it.
On the desk, `nope` in the age field leaves the age at 41, leaves the field text `nope`, and sets `latestMessage` to an error whose source is `age`. Typing `years 41` again clears it. A blank home street leaves the street as it was and records source `home.street` on the person, because the failure is owned by the address and carried onto the parent. The notice label shows `Street is required`. A later real street clears that notice. See [Messages](#messages).
@@ -193,7 +223,7 @@ public String greeting() {
}
```
The library calls `greeting` when the binding is applied, and again when a JavaFX property changes on a `Control` in this view, other than the greeting label, or when an attribute property on this view's `ViewModel` changes. Typing the name updates the greeting because the name field changed. Typing the age updates it because the age attribute changed. Writing the greeting label does not call `greeting` again. Nodes that are not controls are not watched. A replacement view model that does not extend `ViewModel` is not watched.
The library calls `greeting` when the binding is applied, and again when a JavaFX property changes on a `Control` in this view, other than the target property, or when an attribute property on this view's `ViewModel` changes. Typing the name updates the greeting because the name field changed. Typing the age updates it because the age attribute changed. Writing the greeting label does not call `greeting` again. Nodes that are not controls are not watched, and controls added after binding setup are not automatically subscribed. Computed methods should be fast and free of side effects; this is broad notification-based refresh, not dependency analysis. A replacement view model that does not extend `ViewModel` is not watched.
A missing method fails the load with `No computed method "` and the method value. No controller fails with `No controller for`. A method that throws fails with `Could not call`.
@@ -211,7 +241,7 @@ A second view whose document names the same controller class receives that same
## Choosing the ViewModel class
With no annotation, the library builds `ViewModel`. Put `@ViewModelClass` on the controller or the component to name a subclass, or another object that exposes JavaFX properties by name. The class needs a constructor that takes the model instance. If several one-argument constructors match, the most specific parameter type wins. The library calls that constructor. The container does not create the view model.
With no annotation, the library uses a registered generated default when one applies, otherwise it builds `ViewModel`. See the [generated runtime contract](docs/generated-viewmodels.md). Put `@ViewModelClass` on the controller or the component to name a subclass, or another object that exposes JavaFX properties by name. The class needs a constructor that takes the model instance. If several one-argument constructors match, the most specific parameter type wins. The library calls that constructor. The container does not create the view model.
```java
@Singleton
@@ -239,11 +269,11 @@ public class PersonViewModel extends ViewModel {
The desk binds `titleLabel` to path `title`, direction `view`. The label shows `titled-Ada Lovelace`, and it follows the name because the subclass listens to the name property. A binding addresses `titleProperty` by the name `title`.
The first view of an instance fixes the class. A later view that names a different class fails the load with `ViewModel is ` the existing class ` but the View names ` the requested class, and the first view stays as it was. A later view with no annotation reuses the view model. That is what `NameLine.fxml` does.
The first view of an instance fixes the class. A later view requesting a class that the existing ViewModel does not satisfy fails the load with `ViewModel is ` the existing class ` but the View names ` the requested class, and the first view stays as it was. A compatible superclass/interface request reuses the existing instance. A later view with no annotation also reuses the view model. That is what `NameLine.fxml` does.
No matching constructor fails with `No constructor taking the Model on` and the class name.
A component's `@ViewModelClass` applies to that component's model, not to the parent. The library builds that class before the path is read, so a generic view model is not fixed first. An earlier binding in the same list that already resolved a path into that instance would have fixed the class, and a different annotation would then fail the load. The desk does not do that. The person's class is fixed from the controller before the home form's model is set, and `AddressForm` names no view model class, so the address uses `ViewModel`.
A component's `@ViewModelClass` applies to that component's model, not to the parent. The library builds that class before the path is read, so a generic view model is not fixed first. An earlier binding in the same list that already resolved a path into that instance would have fixed the class, and an incompatible annotation would then fail the load. The desk does not do that. The person's class is fixed from the controller before the home form's model is set, and `AddressForm` names no view model class, so the address uses `ViewModel`.
## Component
@@ -255,7 +285,7 @@ Object references such as `model="$address"` retain their identity and are assig
Component's model listener is installed. Static layout constraints such as `GridPane.rowIndex`
remain handled by the containing FXMLLoader.
A component is a class the container can create. It extends a pane, loads `ClassName.fxml` from its own package through `fx:root`, and is the controller of that file. It has no `fx:controller`. It exposes one JavaFX property named `model`.
A component is a class the container can create. It extends `Parent` (usually a Pane subclass such as `VBox`), loads `ClassName.fxml` from its own package through `fx:root`, and is the controller of that file. It has no `fx:controller`. It exposes one JavaFX property named `model`.
`AddressForm` edits the home address. `VisitForm` edits one visit in the repeated list. `NoteForm` edits a note. `VisitRow` edits one table row. Each is `@Singleton`, extends `VBox` or `HBox`, and has `modelProperty()`.
@@ -268,7 +298,7 @@ The home document binds the form and sets `model` from the path. When the path's
The component's own bindings run once, the first time `model` becomes non-null. A later change of `model` does not apply them again. Set the model once. To show another instance, load another component.
`initialize` runs during the component's FXML load, before `model` is set. `AddressForm` uses that moment to copy the injected heading onto its label, which does not need the address. `setViewModel` on a component runs after `model` is set to a non-null model and before that component's bindings. `AddressForm` implements `HasViewModel` and writes `Opened as ` plus the city at that moment. Editing the city afterward leaves the label on the city the form opened with. The sample home opens as `Opened as Sample City`.
`initialize` runs during the component's FXML load, before `model` is set. `AddressForm` uses that moment to copy the injected heading onto its label, which does not need the address. For a Component implementing `HasViewModel`, `setViewModel` runs after `model` is set to a non-null Model and before its bindings, but only when the selected presentation object extends `ViewModel`. An arbitrary replacement does not receive this callback. `AddressForm` implements `HasViewModel` and writes `Opened as ` plus the city at that moment. Editing the city afterward leaves the label on the city the form opened with. The sample home opens as `Opened as Sample City`.
The first component of a class in one load is `context.getComponent`, the cached singleton. Each further one in that same load is `context.createComponent`. Repeat and table rows always use `createComponent`, so they are new instances and the cached singleton of that class stays unused by the rows. `getComponent(VisitForm.class)` after the desk loads is still the singleton, and the three forms in the visit pane are other instances.
@@ -280,7 +310,7 @@ A component class needs `ClassName.fxml` beside it. A missing document fails wit
A nested component is the same mechanism one level down. The child document names the next class as an element and binds its `model`.
`AddressForm.fxml` binds `noteForm` to path `note` and contains `<NoteForm fx:id="noteForm"/>`. `NoteForm.fxml` binds its text field to path `text`. The sample home note is `Kept at home`. Editing the note field writes `Address.note`.
`AddressForm.fxml` binds `noteForm` to path `note` and contains `<NoteForm fx:id="noteForm"/>`. `NoteForm.fxml` binds its text field to path `text`. The sample home note is `Kept at home`. Editing the note field writes `text` on the existing `Note` held by `Address.note`; it does not replace that Note object.
`VisitForm` does not contain `NoteForm`. A visit added after the desk has loaded is built in a new load. The first component of a class in that load is the cached singleton again, so a `NoteForm` nested in the repeated form would be the instance already showing under the home address. Keeping the note on the home form, which is loaded once, leaves that singleton where it was placed.
@@ -288,7 +318,7 @@ A null `note` leaves the nested binding unapplied. `NoteForm` stays empty and th
## Repeat
A `Repeat` element in the same binding list names a pane, a component class, and a collection attribute. The library places one new component in that pane for each model instance, in list order. Adding or removing an element updates the pane. A permutation or a replacement of an element at the same index does not.
A `Repeat` element in the same binding list names a pane, a component class, and a collection attribute. The library places one new component in that pane for each model instance, in list order. Adding or removing an element updates the pane. A permutation or an extractor update notification does not. Replacing an element with `set(index, newValue)` produces remove/add notifications and is handled.
```xml
<Repeat pane="visitPane" component="de.neitzel.fx.example.view.VisitForm" collection="visits"/>
@@ -363,7 +393,7 @@ A table shows a collection of model instances with column headers. Each element
An empty collection shows the headers and a blank body. Rows scroll vertically inside the table. The header stays put and follows the body's horizontal scroll. Hidden rows are kept and reused when a filter is cleared.
Add and remove update the body. A permutation or an update at the same index is ignored. The table refreshes filter and sort when a text control in a row changes. Other controls in the row do not retrigger it. The field's own direct binding still writes on any property change.
Add and remove update the body. A permutation or an extractor update notification is ignored; `set(index, replacement)` is handled as remove/add. The table refreshes filter and sort when a text control in a row changes. Other controls in the row do not retrigger it. The field's own direct binding still writes on any property change.
A column attribute must be a simple value: string, boolean, enum, a primitive other than `char` and `void`, a `Number`, a `Date`, a `Calendar`, or a `java.time` temporal. `visitedOn` is a `LocalDate`, so it is a column. A complex attribute fails the load with `Attribute "` name `" is not a simple value`.
@@ -433,7 +463,7 @@ The search box hides checklist entries whose text does not contain the query, ig
## Messages
Every view model has a list of messages and a latest message. A message has a severity, a text, and a source.
Every runtime `ViewModel` has a list of messages and a latest message. A replacement object that does not extend `ViewModel` does not automatically provide this message machinery. A message has a severity, a text, and a source.
| Severity | What happens |
| --- | --- |
@@ -449,7 +479,7 @@ viewModel.addMessage(new Message(
"desk"));
```
`getMessages()` is an unmodifiable list, oldest first. `getLatestMessage()` is the most recently added notice. An in-place update of a binding failure does not change which notice is latest, and it is logged again when the severity is error or fatal.
`getMessages()` is an unmodifiable live list, oldest first. There is no public remove/clear operation for application-added messages; converter failures have their own internal recovery removal. `getLatestMessage()` is the most recently added notice. An in-place update of a binding failure does not change which notice is latest, and it is logged again when the severity is error or fatal.
`latestMessage` is also a string property, the text of the latest message, or null when there is none. It is not a model attribute. The notice label binds it toward the view:
@@ -457,7 +487,7 @@ viewModel.addMessage(new Message(
<DirectBinding control="noticeLabel" property="text" path="latestMessage" direction="view"/>
```
The library does not open a popup or fill a status bar on its own. `MessagePopup.showNewMessages(viewModel)` shows each message added after that call. The desk calls it once, after load. The dialog has no header, its content is a label of the message text, and its button is OK. `Dialog.show()` does not block the caller. Messages already on the view model are not shown. An in-place update does not open another dialog.
A `FATAL` notice does not terminate the application. The library does not open a popup or fill a status bar on its own. `MessagePopup.showNewMessages(viewModel)` shows each message added after that call. The desk calls it once, after load. The dialog has no header, its content is a label of the message text, and its button is OK. `Dialog.show()` does not block the caller. Messages already on the view model are not shown. An in-place update does not open another dialog.
A child message is carried onto the parent. The copy is a different `Message`, with the source extended by the attribute name, so `street` on an address becomes `home.street` on the person. The copy is not written to the log again. When the child drops the message, the parent drops that copy. An in-place text change updates the copy. Setting the complex attribute to null drops the parent's copies and leaves the child's own message. Setting a new child copies the messages that child already has. The same address object on two attributes carries the notice under both attribute names. A cycle does not copy a message back onto a view model that already holds it.
@@ -488,12 +518,12 @@ These are the `IllegalArgumentException` messages a document can produce. The vi
| `No controller ` | `fx:controller` names a missing class |
| `No control "` | `fx:id` is missing |
| `No property ` | the control has no such JavaFX property |
| `Not a direction:` | direction is not `view`, `model`, or `both` |
| `Not a direction:` | an explicitly supplied direction is not `view`, `model`, or `both`; an omitted direction can instead cause `NullPointerException` |
| `No read-write attribute for path "` | the path is not an attribute |
| `Not a binding element:` | a child of `BindingList` is not a direct binding, a computed binding, or a repeat |
| `No converter ` / `Not a converter:` | the converter class is missing or does not implement `Converter` |
| `No controller for ` / `No computed method "` | a computed binding has no controller or no such method |
| `ViewModel is ` … ` but the View names ` | a later view names a different view model class |
| `ViewModel is ` … ` but the View names ` | a later view requests an incompatible view model class |
| `No constructor taking the Model on ` | the named view model class has no matching constructor |
| `No component document ` | `ClassName.fxml` is missing |
| `No model property on ` | the component has no `modelProperty` |
@@ -507,3 +537,23 @@ A conversion failure does not fail the load. It records a message, as [Converter
Generated Java ViewModels can share the existing runtime storage and identity registry; see
[the generated ViewModel runtime contract](docs/generated-viewmodels.md) for stored-property
access, declarative service defaults, compatible overrides and module-path registration.
## Public API checklist
The reference above covers the FXML declarations and their behavior. These are the public entry points by responsibility:
| Responsibility | Types and key members |
| --- | --- |
| Load | `ViewLoader.load`, `LoadedView.getRoot`, `getViewModel`, `property` |
| Presentation state | `ViewModel.property`, protected `storedProperty`, `HasViewModel.setViewModel`, `@ViewModelClass` |
| Declarations | `BindingList`, `DirectBinding`, `ComputedBinding`, `Repeat`; JavaBean setters correspond to the FXML attributes described above |
| Convert values | `Converter.toControlValue`, `toAttributeValue` |
| Table | `Table` collection/row/columns/selection, `Column` header/attribute/width |
| Messages | `Message` severity/text/source, `Message.Severity`, `ViewModel.addMessage`, `getMessages`, `getLatestMessage`, `MessagePopup.showNewMessages` |
| Generated defaults | `ViewModelRegistry`, `ViewModelRegistration`, `ViewModelRegistrations`, `ViewModelDefaults.select` |
| Independent helpers | `IntegerSpinners.nonNegative`, `TraySupport` and its `Backend`/`Registration` |
`property(name)` returns `Property<?>`: cast to the known property type when writing from Java, for example `((StringProperty) loaded.property("name")).set("Grace")`. Generated accessors avoid these casts. `storedProperty` is for subclasses and reads only stored Model Attributes; it does not invoke custom reflective accessors. Internal loaders, appliers and identity-cache helpers are implementation details, not extension APIs.
For threading, strong identity retention, context reuse, the absence of a general disposal API, and JPMS reflection, see [architecture and lifetime](docs/architecture.md). For an independent application POM and runnable files, use the [complete quick start](docs/quick-start.md).
+71
View File
@@ -0,0 +1,71 @@
# Architecture and lifetime
## Model, View and ViewModel
The Model is an ordinary JavaBean representing application data. A View is an FXML document and its JavaFX controls. The ViewModel makes Model Attributes observable and writes changes back to the bean. This is the separation behind Model–View–ViewModel (MVVM): controls depend on observable presentation state rather than carrying persistence or business logic themselves.
NeitzelLib creates that presentation layer at runtime. You can subclass it for additional properties, choose a replacement object, or generate typed Java accessors. None of these choices generates a controller automatically. A controller remains an optional, application-written object for event handlers, Computed methods and screen behavior. A Component combines a reusable JavaFX root, its own FXML, and a Model property.
```mermaid
flowchart TD
App[Application: Model + ApplicationContext] --> Loader[ViewLoader]
FXML[FXML: controls and binding declarations] --> Loader
Loader --> View[LoadedView: JavaFX root]
Loader --> VM[Runtime ViewModel]
Model[JavaBean Model] -->|Initial getter read / lazy child resolution| VM
VM -->|Property change calls setter| Model
View <-->|DirectBinding| VM
Context[ApplicationContext] --> Controller[Controller / Computed methods]
Context --> Converter[Converters]
Context --> Component[Component root and controller]
Controller --> View
Converter --> View
Component --> View
Registry[Optional generated ViewModel registry] --> VM
```
For the person desk, `Person` is the Model, `Desk.fxml` the View, `PersonViewModel` adds `title`, and `DeskController` handles buttons and calculates the greeting. `AddressForm` renders an Address, `VisitRow` renders a Table row, and converters adapt model values to editable text. The [example walkthrough](../../fx-example/README.md) connects these files to visible behavior.
The [dependency baseline](../README.md#dependencies) distinguishes application runtime dependencies from optional build and Designer tooling.
## Loading order
`ViewLoader.load` reads the controller declaration and its ViewModel selection, obtains or creates the Model's ViewModel, then loads FXML. The injection context supplies controllers, Components and Converters. JavaFX performs normal `@FXML` field injection and calls `initialize`. After loading, the library supplies the ViewModel to `HasViewModel` only when the selected object extends `ViewModel` (replacement objects do not receive this callback), applies BindingLists in document order, and opens custom Tables.
Consequently, `initialize` must not assume that `HasViewModel.setViewModel` has already run. A Component's own bindings wait for its first non-null Model. Binding order can matter when an earlier declaration causes a nested Model's ViewModel to be created before a later Component requests a more specific class.
`LoadedView` exposes the root, named properties and the runtime ViewModel. `getViewModel()` returns the generic `ViewModel` or its subclass and throws `IllegalStateException` for a replacement that does not extend it. `property(name)` also supports a replacement exposing named JavaFX properties and returns null for an unknown name. A plain `FXMLLoader` does not apply NeitzelLib's binding declarations or DI integration.
## Observability and identity
Attributes are JavaBeans properties with both a getter and setter. Runtime properties are initialized from the bean and changes to them call its setter. Direct calls to a bean setter do not send notifications back to the runtime property. Update the ViewModel on the JavaFX application thread when application logic should update an existing View.
The runtime holds a **global, strong, identity-based cache** of Model-to-ViewModel associations. The same Model object shares one ViewModel across Views and contexts; equal but distinct beans do not. Registration before traversing children supports cyclic graphs. There is no public eviction or release API. Treat this as an explicit limitation for applications with many short-lived Models.
Complex Attributes are mapped lazily to nested ViewModels. A null child stays null. A dotted binding resolves intermediate objects when the binding is applied; it does not dynamically reattach after replacing an intermediate child. Bind a Component's `model` to the complex Attribute when you want a subform, but note that the current Component implementation also applies its internal bindings only for its first non-null Model.
Lists are shared ObservableLists when possible. A `List` bean setter generally accepts the observable wrapper; a concrete collection setter may not. Non-List collections are copied. Initialize collections before loading, and mutate the observable list exposed by the loaded Model/ViewModel, not an old non-observable backing reference. Replacing a bean's collection with its setter does not update an already loaded ViewModel. Repeat and Table handle add/remove and `set(index, replacement)` notifications, but ignore permutations and extractor update notifications.
## Context and View lifetime
The [injection context](../../injection/README.md) caches one instance of every scanned class. This includes controllers and converters. Reusing a controller in multiple live Views overwrites its injected FXML fields. Components reuse the context instance for the first occurrence of a class in a load and create fresh instances for additional occurrences; Repeat and Table explicitly create fresh row roots. Nested Components still need care when the same context is reused across independent loads.
Use a separate context for independent windows unless shared controller/component lifetime is intentional. This does not isolate the global Model identity cache: use separate Model instances if you need independent drafts.
There is no general `LoadedView.close()`, ViewModel disposal, unbind-all operation, or container shutdown hook. Bindings, collection listeners and MessagePopup listeners can retain objects. Closing a Stage does not constitute a documented cleanup contract for these listeners. Install popup listeners once, keep application lifetimes bounded, and test long-running/repeated-window workloads before depending on this snapshot.
Run loading, scene changes, observable property updates and message operations on the JavaFX application thread. The context and ViewModel cache are not concurrent services. Perform slow I/O separately and deliver results with `Platform.runLater`; do not perform it inside Computed methods or property listeners.
## Modules and reflection
The library artifacts currently act as automatic modules named `fx` and `injection`. An application using JPMS generally needs `requires fx`, `requires injection`, `requires jakarta.inject`, `requires javafx.controls` and `requires javafx.fxml`. The precise requirements depend on its imports.
Open bean packages to `fx` for reflective Attribute access. FXML controller and Component packages need reflective access for JavaFX and the library/container; an unqualified `opens your.controls;` is a straightforward starting point while diagnosing a modular application. Injection may invoke non-public constructors and members. Generated ViewModel packages also require suitable exports/opens, and a named-module registry uses `provides de.neitzel.fx.view.ViewModelRegistry with ...` rather than only a classpath service file.
Package scanning depends on the runtime's class/module paths. Verify the real modular launch; success on the classpath does not prove a module configuration is correct. The Designer separately validates classpath and module-path target applications and keeps its Kit runtime distinct from the application's JavaFX runtime.
## Choosing extensions
Start with generic runtime properties. Add a ViewModel subclass for derived presentation state and a controller for event handling. Use [generated ViewModels](generated-viewmodels.md) for typed access and shared discovery metadata. Use [Components, Repeat and Table](../README.md#component) to reuse form structure. The Designer is an optional authoring tool: the application remains runnable without it.
The library is a development snapshot and a personal helper collection. In particular, it is not a transactional form engine, a virtualized table, a dependency-tracking expression language, or a full dependency injection framework. Keep business validation and persistence boundaries explicit in application code.
+51 -1
View File
@@ -6,4 +6,54 @@ Modules contribute public `ViewModelRegistry` marker services annotated with rep
`ViewModelDefaults.select` checks duplicate/conflicting defaults, finds the most-specific applicable Model declaration, and requires an explicit override to extend the corresponding generated base. No declaration preserves generic/default replacement behavior. A compatible explicit request takes precedence before creation. Existing instances are reused for a requested superclass/interface that they satisfy; incompatible requests fail. Nested lazy loading uses the same registry and identity table as direct loading. Failed subclass construction removes its partially registered instance.
Generated Java source and compiler integration live in NeitzelFXDesigner. This library API is a development snapshot, not a published release. Runtime regressions cover stored identity/write-back, cycles, null children, Messages, compatible/incompatible requests, most-specific defaults, provider non-execution, conflicting contributions and constructor failure cleanup. Existing ungenerated Model and Component tests remain required.
Generated Java source and compiler integration live in NeitzelFXDesigner; its [generation guide](https://gitea.neitzel.de/konrad/NeitzelFXDesigner/src/branch/main/docs/user-guide/generation.md) explains processor setup, generated defaults and reviewed FXML creation. This library API is a development snapshot, not a published release. Runtime regressions cover stored identity/write-back, cycles, null children, Messages, compatible/incompatible requests, most-specific defaults, provider non-execution, conflicting contributions and constructor failure cleanup. Existing ungenerated Model and Component tests remain required.
## What a generated class represents
For a Model with a `String name` Attribute, a generated base can expose the existing property without creating a second storage location:
```java
package example.generated;
import de.neitzel.fx.view.ViewModel;
import javafx.beans.property.StringProperty;
public class PersonViewModel extends ViewModel {
public PersonViewModel(example.Person model) { super(model); }
public StringProperty nameProperty() {
return (StringProperty) storedProperty("name");
}
}
```
The generator owns this base; put handwritten derived properties in a subclass. The generic runtime already supports the Model without generated code. Generation adds typed access and discoverable contracts, not a separate binding engine or an automatically generated event controller.
A registry contribution for the generated base itself as default looks like this:
```java
package example.generated;
import de.neitzel.fx.view.ViewModelRegistry;
import de.neitzel.fx.view.ViewModelRegistration;
@ViewModelRegistration(model = example.Person.class,
generated = PersonViewModel.class,
defaultType = PersonViewModel.class)
public final class Registry implements ViewModelRegistry { }
```
On the classpath, put this line in `src/main/resources/META-INF/services/de.neitzel.fx.view.ViewModelRegistry`:
```text
example.generated.Registry
```
For JPMS, declare the service in the application module instead:
```java
provides de.neitzel.fx.view.ViewModelRegistry with example.generated.Registry;
```
The selected default may be a handwritten subclass of the generated base. `@ViewModelRegistration` is repeatable; its container is `@ViewModelRegistrations`. Conflicting defaults, ambiguous equally applicable Model declarations, or a default/explicit override outside the required generated base fail rather than silently choosing one. `ViewModelDefaults.select(modelClass, explicitClass, classLoader)` is the public selection operation used by runtime integration; normal applications let `ViewLoader` perform it.
Keep the generated registry and classes on the same application runtime path as the Model. If discovery succeeds during compilation but the registry service is absent at runtime, the runtime cannot select that default. Do not change the selected class after an instance's ViewModel has already been created: only requests compatible with the existing instance can reuse it.
+12
View File
@@ -12,3 +12,15 @@ the editor. Increment/decrement use the normal JavaFX spinner behavior.
The factory rejects negative/inverted bounds and out-of-range initial values.
Applications own their field labels, units, and domain-specific bounds.
Create and add a spinner in a controller's `initialize` method or in `Application.start`:
```java
Spinner<Integer> quantity = IntegerSpinners.nonNegative(0, 100, 1);
quantity.valueProperty().addListener((observable, oldValue, newValue) ->
System.out.println("Quantity: " + newValue));
container.getChildren().add(quantity);
```
Import `javafx.scene.control.Spinner` and `de.neitzel.fx.control.IntegerSpinners`; `container` is your Pane. Read `quantity.getValue()` to obtain the committed number. Set a programmatic value through `quantity.getValueFactory().setValue(...)`. Use the factory's bounds as UI constraints and keep independent business validation where the value is saved. The helper does not add an FXML binding declaration or create a Model Attribute automatically.
+203
View File
@@ -0,0 +1,203 @@
# Your first JavaFX application
This walkthrough creates a complete Maven project with one editable name and a label that follows it. It uses Java 21, JavaFX 21.0.7 and the current `1.0-SNAPSHOT` NeitzelLib sources. These are local development artifacts; the instructions do not assume that this snapshot is published in Maven Central.
## Prepare the library
Install a JDK 21. Use the library's Maven wrapper (3.9.11), or a separate Maven installation version 3.8.6 or newer. Check `java -version` and `./mvnw -version` before building; on macOS, select the JDK with `export JAVA_HOME=$(/usr/libexec/java_home -v 21)` if necessary.
```bash
git clone https://gitea.neitzel.de/konrad/NeitzelLib.git
cd NeitzelLib
./mvnw -pl fx-example -am -Dmaven.test.failure.ignore=false install
./mvnw -f fx-example/pom.xml javafx:run
```
The `install` command installs the parent POM, `core`, `injection` and `fx` into your local Maven repository and builds the example. The repository hardcodes Surefire failure-ignore, so Maven success alone does not establish passing tests, even with the explicit flag. Inspect the XML reports as shown below. The `javafx:run` command opens the [person desk example](../../fx-example/README.md). Run `javafx:run` only on the example POM, not with `-am`: the reactor parent is not a JavaFX application.
Check test results after the build (requires Python 3):
```bash
python3 - <<'PYTHON'
from pathlib import Path
import xml.etree.ElementTree as ET
reports = list(Path('.').glob('*/target/surefire-reports/TEST-*.xml'))
assert reports, 'No test reports found'
failures = []
for report in reports:
suite = ET.parse(report).getroot()
if int(suite.get('failures', 0)) or int(suite.get('errors', 0)):
failures.append(str(report))
assert not failures, 'Failed test reports: ' + ', '.join(failures)
print(f'{len(reports)} test suites passed')
PYTHON
```
Use a clean build when qualifying a change so stale reports cannot stand in for tests that did not run.
## Create the application
Create a separate directory `hello-fx` with these files. This is a classpath application; it deliberately has no `module-info.java`.
```text
hello-fx/
pom.xml
src/main/java/example/Main.java
src/main/java/example/HelloApp.java
src/main/java/example/Person.java
src/main/resources/example/Hello.fxml
```
`pom.xml`:
```xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>hello-fx</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>de.neitzel.lib</groupId>
<artifactId>fx</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.17</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.openjfx</groupId>
<artifactId>javafx-maven-plugin</artifactId>
<version>0.0.8</version>
<configuration><mainClass>example.Main</mainClass></configuration>
</plugin>
</plugins>
</build>
</project>
```
`src/main/java/example/Person.java`:
```java
package example;
public final class Person {
private String name = "Ada";
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
```
`src/main/java/example/Main.java`:
```java
package example;
import javafx.application.Application;
public final class Main {
public static void main(String[] args) {
Application.launch(HelloApp.class, args);
}
}
```
`src/main/java/example/HelloApp.java`:
```java
package example;
import de.neitzel.fx.view.LoadedView;
import de.neitzel.fx.view.ViewLoader;
import de.neitzel.injection.ApplicationContext;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.stage.Stage;
public final class HelloApp extends Application {
@Override
public void start(Stage stage) throws Exception {
Person person = new Person();
ApplicationContext context = new ApplicationContext("example");
LoadedView view = ViewLoader.load(
HelloApp.class.getResource("Hello.fxml"), person, context);
stage.setScene(new Scene(view.getRoot()));
stage.setTitle("Hello NeitzelLib");
stage.show();
}
}
```
`src/main/resources/example/Hello.fxml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<VBox xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1"
spacing="12" prefWidth="360" prefHeight="120">
<fx:define>
<BindingList>
<DirectBinding control="name" property="text" path="name" direction="both"/>
<DirectBinding control="echo" property="text" path="name" direction="view"/>
</BindingList>
</fx:define>
<TextField fx:id="name"/>
<Label fx:id="echo"/>
</VBox>
```
From `hello-fx`, run:
```bash
mvn clean compile javafx:run
```
Without a separate Maven installation, use the absolute path to the cloned library's `mvnw` with `-f /absolute/path/to/hello-fx/pom.xml clean compile javafx:run`.
Type another name: the label follows immediately, and the `Person` setter receives the new value. No controller is required for this form. `ViewLoader` creates a runtime ViewModel that holds the JavaFX properties. Updating `person.setName(...)` directly later does **not** notify the ViewModel; update `((javafx.beans.property.StringProperty) view.property("name")).set("Grace")` on the JavaFX application thread instead.
## Add behavior and reusable forms
Use an injected controller for event handlers or Computed bindings, and a Component for a reusable FXML form. The [person desk](../../fx-example/README.md) demonstrates both, converters, lists, tables and messages. Read the [architecture guide](architecture.md) before introducing nested Models or sharing windows: Model identity, controller lifetime and list notifications matter.
`fx` brings `core`, `injection` and JavaFX base, graphics, controls, FXML and web transitively. You do not need Lombok or a generated ViewModel to use this example. Java ViewModel generation and the Designer are optional tools, not prerequisites for loading an FXML view.
For a named-module application, see the [module and lifecycle notes](architecture.md#modules-and-reflection). Do not simply add `module-info.java` without opening the packages used through reflection.
## Troubleshooting the first launch
| Symptom | Check |
| --- | --- |
| Maven cannot resolve `de.neitzel.lib:fx:1.0-SNAPSHOT` or its parent | Install the library reactor first; use the same Maven local repository for both projects. These instructions use local snapshots. |
| `No plugin found for prefix 'javafx'` | Invoke the example POM directly, or use the complete application POM above. Do not invoke the goal on the library reactor parent. |
| JavaFX runtime/native library failure | Check the JDK version, architecture and JavaFX dependencies actually resolved; avoid mixing native artifacts from different platforms or JavaFX versions. |
| `getResource(...)` returns null | The FXML belongs under `src/main/resources` in the matching package; verify it was copied to `target/classes`. Resource names are case-sensitive. |
| `No component found for type` | Annotate the controller/converter/Component, include it in the context's scan root, and inspect `ComponentScanner.getErrors()` for unresolved dependencies. |
| Binding cannot find control/property/path | Match `fx:id`, a public JavaFX `nameProperty()` target, and a readable/writable Model Attribute. Use `ViewLoader`, not plain `FXMLLoader`. |
| Initial data shows but a bean setter does not update the UI | Update the observable ViewModel property; the bean is not itself observable. |
| A second window fails with an already-parented node | Create a separate context for that window; avoid reusing cached Component roots and controllers. |
| Reflective access fails only on module path | Add the appropriate `opens` declarations and verify registry service declarations, not only dependencies. |
Do not build a single ordinary JAR and expect it to include JavaFX's platform-specific runtime automatically. The Maven run goal handles the development launch; deployment requires the application's dependencies or a separately built runtime image. The person desk's JAR-plus-`libs` packaging is an example of the former, and the Designer has its own bundled runtime packaging.
+26
View File
@@ -24,3 +24,29 @@ safe from any thread. This is also the public seam for lifecycle tests. The test
exercise real JavaFX Stage close/open, cancelled/accepted quit, and unsupported
fallback with deterministic native registrations. Native platform behavior must
also be checked in the consuming application.
## Application integration
Keep the handle as application state and give users a Quit button/menu even if the tray is unavailable:
```java
// In Application.start(Stage stage); icon is a java.awt.Image.
tray = TraySupport.attach(stage, icon, "My application",
() -> confirmUnsavedChanges(), Platform::exit);
quitButton.setOnAction(event -> tray.requestQuit());
```
```java
// Application field and shutdown hook:
private TraySupport tray;
@Override
public void stop() {
if (tray != null) tray.close();
}
```
Import `de.neitzel.fx.tray.TraySupport` and `javafx.application.Platform`. Supply your own `java.awt.Image`, confirmation function and Button. This is an integration fragment, not a complete application. `mayQuit` must return whether quitting may proceed; the library does not save documents itself. `isAvailable()` reads current availability, while the read-only `availableProperty()` can drive a status label. A pending installation is not availability.
Use one owner for the application's primary tray lifecycle: JavaFX implicit exit is global, so independent handles are not a coordinated multi-window policy. This API does not intercept every operating-system application Quit action. `close()` is idempotent and releases only its own registration and handlers; ordinary application resources still need their own cleanup.
@@ -36,11 +36,11 @@ final class ViewModels {
/**
* 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.
* Selects a registered default or compatible explicit class before construction. Without
* a registered default, a null class or {@link ViewModel} itself uses the generic ViewModel.
* An unregistered replacement may expose JavaFX properties by name. The container does not
* create it. The first View fixes the instance; later compatible superclass/interface requests
* reuse it, while incompatible requests fail. A View naming no class reuses the instance.
*
* @param model the Model instance
* @param viewModelClass the class named by {@link ViewModelClass}, or {@code null}
+51 -1
View File
@@ -8,7 +8,7 @@ The worked example is the person desk in `fx-example`, package `de.neitzel.fx.ex
## Quick start
Depend on the module. It brings Reflections and `jakarta.inject-api`. The parent artifact is `de.neitzel.lib:neitzellib:1.0-SNAPSHOT`. Java 21.
Depend on the module. It brings Reflections 0.10.2 and `jakarta.inject-api` 2.0.1 (plus shared logging dependencies). The parent artifact is `de.neitzel.lib:neitzellib:1.0-SNAPSHOT`. Java 21.
```xml
<dependency>
@@ -171,3 +171,53 @@ The desk does not call `addSingleton`. Its services are the scanned classes.
## What this container does not do
It does not match qualifiers, read `@Named` values, inject by name, bind configuration properties, or call lifecycle methods. It does not create a new instance per injection. `fx` view models are built by `fx`, not by this container.
## A standalone service example
Injection does not require a JavaFX application or toolkit. After installing the local snapshot as described in the [complete Maven quick start](../fx/docs/quick-start.md), use the dependency above and put these public types in separate files in the same `example.services` package:
```java
package example.services;
@jakarta.inject.Singleton
public class Greeting {
public String text() { return "Hello"; }
}
```
```java
package example.services;
@jakarta.inject.Named
public class Printer {
private final Greeting greeting;
public Printer(Greeting greeting) { this.greeting = greeting; }
public void print() { System.out.println(greeting.text()); }
}
```
```java
package example.services;
import de.neitzel.injection.ApplicationContext;
public class Main {
public static void main(String[] args) {
var context = new ApplicationContext("example.services");
context.getComponent(Printer.class).print();
// A fresh Printer still receives the same Greeting singleton.
context.createComponent(Printer.class).print();
}
}
```
The lone constructor needs no `@Inject`. Use explicit `@Inject` when choosing among constructors. Keep a scan root narrow enough to contain the intended implementations and their dependencies. For named modules, open reflected packages; see the [reflection notes](../fx/docs/architecture.md#modules-and-reflection).
## Low-level metadata API
`ComponentScanner` exposes its instantiable component map, ambiguous type set and error list for diagnostics. These are its own mutable collections, not immutable snapshots; avoid changing them while inspecting results. It resolves dependencies before instance construction, which is why registering a missing service later cannot repair an already rejected component.
`ComponentData` represents discovered construction metadata: component type, `Scope`, selected constructor, injected fields and setters, and cached instance. Its constructors accept a type/scope, a prebuilt instance, or complete construction metadata. Member lists are copied at construction. `setInstance` stores an instance only for `SINGLETON` and rejects a second assignment; it does not cache a `PROTOTYPE`. This is a low-level representation, not a builder for reconfiguring an existing context. Prefer `ApplicationContext` for application code.
A manually supplied singleton does not receive member injection. The container does not close supplied or constructed objects. Your application owns their resources and shutdown, and must serialize access if it uses a context across threads.
+17
View File
@@ -0,0 +1,17 @@
{
"schemaVersion": "1.0",
"contexts": {
"person-desk": {
"applicationModule": "fx-example",
"mavenProfiles": ["designer-preview"],
"launchMode": "classpath",
"buildRuntime": "java21",
"targetRuntime": "java21",
"preview": {
"sourceSet": "test",
"provider": "de.neitzel.fx.example.preview.DeskPreviewProvider",
"defaultExample": "desk"
}
}
}
}