diff --git a/fx/README.md b/fx/README.md
index 16c1c9b..dc1ecc4 100644
--- a/fx/README.md
+++ b/fx/README.md
@@ -503,3 +503,7 @@ These are the `IllegalArgumentException` messages a document can produce. The vi
| `No component found for type:` | the container was asked for a controller, component, or converter it did not scan |
A conversion failure does not fail the load. It records a message, as [Converter](#converter) describes.
+
+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.
diff --git a/fx/docs/generated-viewmodels.md b/fx/docs/generated-viewmodels.md
new file mode 100644
index 0000000..0a62a7e
--- /dev/null
+++ b/fx/docs/generated-viewmodels.md
@@ -0,0 +1,9 @@
+# Generated ViewModel runtime contract
+
+A generator may extend `ViewModel` with typed constructors and accessors. Accessors call the protected final `storedProperty(name)` method, which returns the existing Attribute property and never invokes a reflective accessor. Unknown stored Attributes fail explicitly. Property storage, write-back, lazy nested Models, collection contents and Messages remain owned by `ViewModel`.
+
+Modules contribute public `ViewModelRegistry` marker services annotated with repeatable `ViewModelRegistration` declarations. Each declaration names the configured Model, generated base and selected default class. Registries are inspected through `ServiceLoader.Provider.type()` without constructing them; their annotations are also available to non-initializing designer discovery. Classpath applications package `META-INF/services/de.neitzel.fx.view.ViewModelRegistry`; named modules declare `provides` and export/open generated ViewModel packages as appropriate.
+
+`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.
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
index 64950d9..9cf5adc 100644
--- a/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewModel.java
@@ -169,6 +169,13 @@ public class ViewModel {
}
}
+ /** Returns a stored Attribute only, without invoking a generated or custom accessor. */
+ protected final Property> storedProperty(String name) {
+ Property> property = properties.get(name);
+ if (property == null) throw new IllegalArgumentException("No stored Attribute: " + name);
+ return property;
+ }
+
/**
* Returns the JavaFX property for an Attribute name.
*
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModelDefaults.java b/fx/src/main/java/de/neitzel/fx/view/ViewModelDefaults.java
new file mode 100644
index 0000000..0a89357
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewModelDefaults.java
@@ -0,0 +1,65 @@
+package de.neitzel.fx.view;
+
+import java.util.*;
+
+/** Resolves declarative services without constructing providers or Models. */
+public final class ViewModelDefaults {
+ private ViewModelDefaults() {}
+
+ public static Class> select(Class> model, Class> explicit, ClassLoader loader) {
+ var entries = new HashMap, ViewModelRegistration>();
+ var origins = new HashMap, String>();
+ for (var provider : ServiceLoader.load(ViewModelRegistry.class, loader).stream().toList()) {
+ Class> type = provider.type();
+ for (var entry : type.getAnnotationsByType(ViewModelRegistration.class)) {
+ if (!entry.generated().isAssignableFrom(entry.defaultType())) {
+ throw new IllegalArgumentException(
+ "Default must extend generated ViewModel: " + type.getName());
+ }
+ var old = entries.putIfAbsent(entry.model(), entry);
+ if (old != null
+ && (old.generated() != entry.generated()
+ || old.defaultType() != entry.defaultType())) {
+ throw new IllegalArgumentException(
+ "Conflicting ViewModel defaults for "
+ + entry.model().getName()
+ + " in "
+ + origins.get(entry.model())
+ + " and "
+ + type.getName());
+ }
+ origins.putIfAbsent(entry.model(), type.getName());
+ }
+ }
+ var candidates =
+ entries.values().stream().filter(e -> e.model().isAssignableFrom(model)).toList();
+ var best =
+ candidates.stream()
+ .filter(
+ e ->
+ candidates.stream()
+ .noneMatch(
+ other ->
+ other != e
+ && e.model()
+ .isAssignableFrom(
+ other
+ .model())))
+ .toList();
+ if (best.size() > 1)
+ throw new IllegalArgumentException(
+ "Ambiguous ViewModel defaults for " + model.getName());
+ var chosen = best.isEmpty() ? null : best.getFirst();
+ if (explicit != null && explicit != ViewModel.class) {
+ if (chosen != null && !chosen.generated().isAssignableFrom(explicit)) {
+ throw new IllegalArgumentException(
+ "Explicit ViewModel "
+ + explicit.getName()
+ + " must extend "
+ + chosen.generated().getName());
+ }
+ return explicit;
+ }
+ return chosen == null ? ViewModel.class : chosen.defaultType();
+ }
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistration.java b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistration.java
new file mode 100644
index 0000000..edd4171
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistration.java
@@ -0,0 +1,15 @@
+package de.neitzel.fx.view;
+
+import java.lang.annotation.*;
+
+/** A compiled, non-executing default declaration contributed by a registry service. */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+@Repeatable(ViewModelRegistrations.class)
+public @interface ViewModelRegistration {
+ Class> model();
+
+ Class extends ViewModel> generated();
+
+ Class extends ViewModel> defaultType();
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistrations.java b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistrations.java
new file mode 100644
index 0000000..f0f58f6
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistrations.java
@@ -0,0 +1,10 @@
+package de.neitzel.fx.view;
+
+import java.lang.annotation.*;
+
+/** Container for generated registry declarations. */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface ViewModelRegistrations {
+ ViewModelRegistration[] value();
+}
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistry.java b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistry.java
new file mode 100644
index 0000000..d805d25
--- /dev/null
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewModelRegistry.java
@@ -0,0 +1,7 @@
+package de.neitzel.fx.view;
+
+/**
+ * Marker service for declarative generated ViewModel registrations. Providers are never
+ * instantiated.
+ */
+public interface ViewModelRegistry {}
diff --git a/fx/src/main/java/de/neitzel/fx/view/ViewModels.java b/fx/src/main/java/de/neitzel/fx/view/ViewModels.java
index b7c6b73..e61143f 100644
--- a/fx/src/main/java/de/neitzel/fx/view/ViewModels.java
+++ b/fx/src/main/java/de/neitzel/fx/view/ViewModels.java
@@ -47,20 +47,19 @@ final class ViewModels {
* @return the ViewModel for that instance, or the replacement
*/
static Object of(Object model, Class> viewModelClass) {
+ Class> selected = ViewModelDefaults.select(model.getClass(), viewModelClass, model.getClass().getClassLoader());
Object existing = BY_INSTANCE.get(model);
if (existing != null) {
if (viewModelClass != null && viewModelClass != ViewModel.class
- && existing.getClass() != viewModelClass) {
+ && !selected.isInstance(existing)) {
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);
+ if (selected == ViewModel.class) return new ViewModel(model);
+ return construct(model, selected);
}
/**
@@ -101,6 +100,7 @@ final class ViewModels {
}
return created;
} catch (InvocationTargetException failure) {
+ BY_INSTANCE.remove(model);
if (failure.getCause() instanceof RuntimeException runtime) {
throw runtime;
}
diff --git a/fx/src/test/java/de/neitzel/fx/view/GeneratedViewModelTest.java b/fx/src/test/java/de/neitzel/fx/view/GeneratedViewModelTest.java
new file mode 100644
index 0000000..a8af5a3
--- /dev/null
+++ b/fx/src/test/java/de/neitzel/fx/view/GeneratedViewModelTest.java
@@ -0,0 +1,152 @@
+package de.neitzel.fx.view;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import javafx.beans.property.*;
+
+import org.junit.jupiter.api.Test;
+
+public class GeneratedViewModelTest {
+ public static class Node {
+ private String name = "sample";
+ private Node child;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String n) {
+ name = n;
+ }
+
+ public Node getChild() {
+ return child;
+ }
+
+ public void setChild(Node n) {
+ child = n;
+ }
+ }
+
+ public static class Special extends Node {}
+
+ public static class Generated extends ViewModel {
+ public Generated(Node n) {
+ super(n);
+ }
+
+ public StringProperty nameProperty() {
+ return (StringProperty) storedProperty("name");
+ }
+
+ @SuppressWarnings("unchecked")
+ public Property childProperty() {
+ return (Property) (Property>) storedProperty("child");
+ }
+
+ public Property> missingProperty() {
+ return storedProperty("missing");
+ }
+ }
+
+ public static class Custom extends Generated {
+ public Custom(Node n) {
+ super(n);
+ }
+ }
+
+ public static class Other extends Generated {
+ public Other(Node n) {
+ super(n);
+ }
+ }
+
+ public static class Specific extends Generated {
+ public Specific(Special n) {
+ super(n);
+ }
+ }
+
+ @ViewModelRegistration(
+ model = Node.class,
+ generated = Generated.class,
+ defaultType = Custom.class)
+ @ViewModelRegistration(
+ model = Special.class,
+ generated = Specific.class,
+ defaultType = Specific.class)
+ public static class Registry implements ViewModelRegistry {
+ public Registry() {
+ throw new AssertionError("Provider executed");
+ }
+ }
+
+ @Test
+ void storedPropertiesWriteBackAndShareCyclesAndMessages() {
+ Node n = new Node();
+ n.setChild(n);
+ var vm = (Custom) ViewModels.of(n);
+ assertSame(vm.nameProperty(), vm.property("name"));
+ vm.nameProperty().set("changed");
+ assertEquals("changed", n.getName());
+ assertSame(vm, vm.childProperty().getValue());
+ assertSame(vm, ViewModels.of(n, Generated.class));
+ vm.addMessage(new Message(Message.Severity.INFO, "notice", "name"));
+ assertEquals("notice", vm.property("latestMessage").getValue());
+ assertThrows(IllegalArgumentException.class, vm::missingProperty);
+ assertThrows(IllegalArgumentException.class, () -> ViewModels.of(n, Other.class));
+ }
+
+ @Test
+ void explicitAndMostSpecificDefaultsAndNullChildren() {
+ Node n = new Node();
+ var custom = (Other) ViewModels.of(n, Other.class);
+ assertNull(custom.childProperty().getValue());
+ assertSame(custom, ViewModels.of(n));
+ assertInstanceOf(Specific.class, ViewModels.of(new Special()));
+ assertThrows(IllegalArgumentException.class, () -> ViewModels.of(new Node(), String.class));
+ }
+
+ @ViewModelRegistration(
+ model = Node.class,
+ generated = Generated.class,
+ defaultType = Other.class)
+ public static class Conflict implements ViewModelRegistry {}
+
+ public static class Failing extends Generated {
+ public Failing(Node n) {
+ super(n);
+ throw new IllegalStateException("failed constructor");
+ }
+ }
+
+ @Test
+ void conflictingServicesIdentifyBothOrigins() throws Exception {
+ var folder = java.nio.file.Files.createTempDirectory("viewmodel-services-");
+ var service = folder.resolve("META-INF/services/de.neitzel.fx.view.ViewModelRegistry");
+ java.nio.file.Files.createDirectories(service.getParent());
+ java.nio.file.Files.writeString(service, Conflict.class.getName());
+ try (var loader =
+ new java.net.URLClassLoader(
+ new java.net.URL[] {folder.toUri().toURL()}, getClass().getClassLoader())) {
+ var error =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> ViewModelDefaults.select(Node.class, null, loader));
+ assertTrue(error.getMessage().contains(Registry.class.getName()));
+ assertTrue(error.getMessage().contains(Conflict.class.getName()));
+ } finally {
+ try (var paths = java.nio.file.Files.walk(folder)) {
+ for (var p : paths.sorted(java.util.Comparator.reverseOrder()).toList())
+ java.nio.file.Files.delete(p);
+ }
+ }
+ }
+
+ @Test
+ void failedConstructionDoesNotRetainPartialInstance() {
+ Node n = new Node();
+ assertThrows(IllegalStateException.class, () -> ViewModels.of(n, Failing.class));
+ assertInstanceOf(Custom.class, ViewModels.of(n));
+ }
+}
diff --git a/fx/src/test/resources/META-INF/services/de.neitzel.fx.view.ViewModelRegistry b/fx/src/test/resources/META-INF/services/de.neitzel.fx.view.ViewModelRegistry
new file mode 100644
index 0000000..d75ee65
--- /dev/null
+++ b/fx/src/test/resources/META-INF/services/de.neitzel.fx.view.ViewModelRegistry
@@ -0,0 +1 @@
+de.neitzel.fx.view.GeneratedViewModelTest$Registry