diff --git a/README.md b/README.md index 3afcabf..244aba4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with minimal standalone and The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits. -The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo. Broader binding authoring, generation, structural editing and product packaging remain later work. +The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). Broader binding authoring, generation, structural editing and product packaging remain later work. Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats and validation rules. diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java index 4ffe81f..173f9c7 100644 --- a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java +++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/BindingMetadata.java @@ -14,7 +14,45 @@ public record BindingMetadata( List problems, String controller, String viewModel, - List computed) { + List computed, + List collections, + List components, + List panes) { + public record RepeatOption(String name, String type, String status, String reason) { + public String toString() { + return name + " : " + type + " — " + status + " (" + reason + ")"; + } + } + + public BindingMetadata( + String model, + String path, + String direction, + List attributes, + List targets, + List diagnostics, + List converters, + List problems, + String controller, + String viewModel, + List computed) { + this( + model, + path, + direction, + attributes, + targets, + diagnostics, + converters, + problems, + controller, + viewModel, + computed, + List.of(), + List.of(), + List.of()); + } + public record Computed(String method, String returnType, String status, String reason) { public String toString() { return method + " : " + returnType + " — " + status + " (" + reason + ")"; @@ -78,13 +116,19 @@ public record BindingMetadata( converters = List.copyOf(converters); problems = List.copyOf(problems); computed = List.copyOf(computed); + collections = List.copyOf(collections); + components = List.copyOf(components); + panes = List.copyOf(panes); if (!Set.of("view", "model", "both").contains(direction)) throw new IllegalArgumentException("Invalid direction"); } public Map encode() { var m = new TreeMap(); - m.put("format", "3"); + m.put("format", "4"); + encodeOptions(m, "rc", collections); + encodeOptions(m, "rt", components); + encodeOptions(m, "rp", panes); m.put("controller", controller); m.put("viewModel", viewModel); m.put("computed", "" + computed.size()); @@ -140,6 +184,33 @@ public record BindingMetadata( return m; } + private static void encodeOptions( + Map m, String prefix, List values) { + m.put(prefix, "" + values.size()); + for (int i = 0; i < values.size(); i++) { + var v = values.get(i); + String p = prefix + "." + i + "."; + m.put(p + "name", v.name()); + m.put(p + "type", v.type()); + m.put(p + "status", v.status()); + m.put(p + "reason", v.reason()); + } + } + + private static List decodeOptions(Map m, String prefix) { + var result = new ArrayList(); + for (int i = 0; i < count(m, prefix); i++) { + String p = prefix + "." + i + "."; + result.add( + new RepeatOption( + field(m, p + "name"), + field(m, p + "type"), + field(m, p + "status"), + field(m, p + "reason"))); + } + return result; + } + private static String field(Map m, String key) { return Objects.requireNonNull(m.get(key), "Missing " + key); } @@ -151,7 +222,7 @@ public record BindingMetadata( } public static BindingMetadata decode(Map m) { - if (!"3".equals(field(m, "format"))) + if (!"4".equals(field(m, "format"))) throw new IllegalArgumentException("Unsupported binding metadata"); var a = new ArrayList(); var t = new ArrayList(); @@ -212,6 +283,9 @@ public record BindingMetadata( v, field(m, "controller"), field(m, "viewModel"), - methods); + methods, + decodeOptions(m, "rc"), + decodeOptions(m, "rt"), + decodeOptions(m, "rp")); } } diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java index 9f7ec71..ac26426 100644 --- a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java +++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java @@ -82,8 +82,8 @@ public final class ProtocolSession implements AutoCloseable { && supported.contains("interactive-v1") && hello.capabilities().contains("interactive-v1")) usable.add(Operation.INTERACTIVE); if (hello.metadata().major() == 1 - && supported.contains("bindings-v3") - && hello.capabilities().contains("bindings-v3")) usable.add(Operation.BINDINGS); + && supported.contains("bindings-v4") + && hello.capabilities().contains("bindings-v4")) usable.add(Operation.BINDINGS); var requiredByEither = new HashSet<>(required); requiredByEither.addAll(hello.required()); if ((requiredByEither.contains("discover-v1") && !usable.contains(Operation.DISCOVER)) @@ -92,7 +92,7 @@ public final class ProtocolSession implements AutoCloseable { if ((requiredByEither.contains("layout-v1") && !usable.contains(Operation.MEASURE)) || (requiredByEither.contains("interactive-v1") && !usable.contains(Operation.INTERACTIVE))) throw new IllegalArgumentException("Required capability version incompatible"); - if (requiredByEither.contains("bindings-v3") && !usable.contains(Operation.BINDINGS)) + if (requiredByEither.contains("bindings-v4") && !usable.contains(Operation.BINDINGS)) throw new IllegalArgumentException("Required capability version incompatible"); operations = Set.copyOf(usable); state = State.READY; diff --git a/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java b/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java index 2a20422..24ff5c1 100644 --- a/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java +++ b/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/BindingMetadataTest.java @@ -26,7 +26,10 @@ class BindingMetadataTest { "app.ViewModel", List.of( new BindingMetadata.Computed( - "#caption", "java.lang.String", "COMPATIBLE", "Declared type"))); + "#caption", "java.lang.String", "COMPATIBLE", "Declared type")), + List.of(new BindingMetadata.RepeatOption("visits", "Address", "COMPATIBLE", "Element")), + List.of(new BindingMetadata.RepeatOption("app.Card", "Address", "COMPATIBLE", "Model")), + List.of(new BindingMetadata.RepeatOption("rows", "VBox", "COMPATIBLE", "Pane"))); assertEquals( m, BindingMetadata.decode(PreviewMessages.decode(PreviewMessages.encode(m.encode())))); var id = new Identity("s", "r", "c", "b", Map.of("file", 1L), 0, 0); @@ -41,14 +44,14 @@ class BindingMetadataTest { new Identity("s", "r", "c", "b", Map.of(), 0, 0), new byte[0])); var invalid = new HashMap<>(m.encode()); - invalid.put("converters", "99999"); + invalid.put("rc", "99999"); assertThrows(IllegalArgumentException.class, () -> BindingMetadata.decode(invalid)); } @Test void negotiationRequiresMetadataVersion() throws Exception { String token = ProtocolSession.newToken(); - var session = new ProtocolSession("s", 1, token, Set.of("bindings-v3"), Set.of("bindings-v3")); + var session = new ProtocolSession("s", 1, token, Set.of("bindings-v4"), Set.of("bindings-v4")); assertTrue( session .authenticate( @@ -60,11 +63,11 @@ class BindingMetadataTest { new Version(1, 0), new Version(1, 0), new Version(1, 0), - Set.of("bindings-v3"), + Set.of("bindings-v4"), Set.of())) .contains(Operation.BINDINGS)); var oldWorker = - new ProtocolSession("s", 1, token, Set.of("bindings-v3"), Set.of("bindings-v3")); + new ProtocolSession("s", 1, token, Set.of("bindings-v4"), Set.of("bindings-v4")); assertThrows( IllegalArgumentException.class, () -> @@ -77,10 +80,10 @@ class BindingMetadataTest { new Version(1, 0), new Version(1, 0), new Version(1, 0), - Set.of("bindings-v2"), + Set.of("bindings-v3"), Set.of()))); var incompatible = - new ProtocolSession("s", 1, token, Set.of("bindings-v3"), Set.of("bindings-v3")); + new ProtocolSession("s", 1, token, Set.of("bindings-v4"), Set.of("bindings-v4")); assertThrows( IllegalArgumentException.class, () -> @@ -93,7 +96,7 @@ class BindingMetadataTest { new Version(1, 0), new Version(2, 0), new Version(1, 0), - Set.of("bindings-v3"), + Set.of("bindings-v4"), Set.of()))); } } diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java index 7905d47..988c097 100644 --- a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java @@ -108,7 +108,7 @@ public final class PreviewWorker implements AutoCloseable { id, process.pid(), token, - Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v3"), + Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v4"), config.requiredCapabilities()); } drain(process.getInputStream(), log); diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingEditor.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingEditor.java index 2108f97..fd235b0 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingEditor.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/BindingEditor.java @@ -24,6 +24,9 @@ final class BindingEditor extends VBox { pane = field("pane"), component = field("component"), collection = field("collection"); + private final ComboBox repeatCollections = new ComboBox<>(), + repeatComponents = new ComboBox<>(), + repeatPanes = new ComboBox<>(); private final ComboBox methods = new ComboBox<>(); private final BindingDocument.Kind kind; private final ComboBox converters = new ComboBox<>(); @@ -36,7 +39,7 @@ final class BindingEditor extends VBox { remove = new Button("Remove binding"); private String source = ""; private Integer selected; - private boolean loading, writable, active, followingCanvas; + private boolean loading, writable, active, followingCanvas, updatingChoices; private final Consumer navigate; BindingEditor( @@ -66,7 +69,7 @@ final class BindingEditor extends VBox { remove.setText("Remove Computed binding"); } if (repeat) { - inspect.setText("Validate Repeat bindings"); + inspect.setText("Discover / Validate Repeat"); fresh.setText("New Repeat binding"); apply.setText("Apply Repeat binding"); remove.setText("Remove Repeat binding"); @@ -141,8 +144,34 @@ final class BindingEditor extends VBox { field.textProperty().addListener((o, a, b) -> methods.getItems().clear()); } if (repeat) { - grid.addRow(0, new Label("Pane (fx:id)"), pane, new Label("Collection path"), collection); - grid.addRow(1, new Label("Component class"), component); + repeatCollections.setId("repeat-collections"); + repeatComponents.setId("repeat-components"); + repeatPanes.setId("repeat-panes"); + repeatCollections.setPrefWidth(420); + repeatComponents.setPrefWidth(680); + repeatPanes.setPrefWidth(420); + repeatCollections.setOnAction( + e -> { + var v = repeatCollections.getValue(); + if (v != null && !updatingChoices) { + collection.setText(v.name()); + discover.accept(values()); + } + }); + repeatComponents.setOnAction( + e -> { + var v = repeatComponents.getValue(); + if (v != null && !updatingChoices) component.setText(v.name()); + }); + repeatPanes.setOnAction( + e -> { + var v = repeatPanes.getValue(); + if (v != null && !updatingChoices) pane.setText(v.name()); + }); + collection.textProperty().addListener((o, a, b) -> repeatComponents.getItems().clear()); + grid.addRow(0, new Label("Pane (fx:id)"), pane, repeatPanes); + grid.addRow(1, new Label("Collection path"), collection, repeatCollections); + grid.addRow(2, new Label("Component class"), component, repeatComponents); } getChildren() .addAll( @@ -209,6 +238,9 @@ final class BindingEditor extends VBox { if (source.equals(text) && writable == editable) return; converters.getItems().clear(); methods.getItems().clear(); + repeatCollections.getItems().clear(); + repeatComponents.getItems().clear(); + repeatPanes.getItems().clear(); source = text; writable = editable; loading = true; @@ -276,16 +308,25 @@ final class BindingEditor extends VBox { if (kind == BindingDocument.Kind.REPEAT) { try { var errors = new BindingDocument(source, kind).errors(); - status.setText( - provenance - + "\n" - + (errors.isEmpty() ? "No structural binding errors." : String.join("\n", errors)) - + "\n" - + "Collection and Component compatibility have not been checked. Render drafts to" - + " test them."); + if (!errors.isEmpty()) status.setText(status.getText() + "\n" + String.join("\n", errors)); } catch (Exception error) { status.setText("Validation unavailable: " + error.getMessage()); } + status.setText( + status.getText() + + "\n" + + "Compatibility uses declarations; construction and runtime values remain preview" + + " checks."); + if (requested != null && values().equals(requested)) { + updatingChoices = true; + try { + repeatCollections.getItems().setAll(metadata.collections()); + repeatComponents.getItems().setAll(metadata.components()); + repeatPanes.getItems().setAll(metadata.panes()); + } finally { + updatingChoices = false; + } + } return; } if (requested == null || !values().equals(requested)) return; @@ -303,6 +344,17 @@ final class BindingEditor extends VBox { } } + void invalidateDiscovery() { + updatingChoices = true; + try { + repeatCollections.getItems().clear(); + repeatComponents.getItems().clear(); + repeatPanes.getItems().clear(); + } finally { + updatingChoices = false; + } + } + void error(String message) { status.setText(message); } diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java index b054776..3cf064a 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java @@ -682,6 +682,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { validationPreview = false; bindingWorkflow.cancel(); bindingBrowser.invalidate(message); + repeatEditor.invalidateDiscovery(); } private void editBinding(BindingEditor.Intent intent) { @@ -744,7 +745,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { .bindingDetails( last, captured, - values == null ? "" : values.path(), + values == null + ? "" + : values.pane().isEmpty() && values.collection().isEmpty() + ? values.path() + : values.collection(), values == null ? "both" : values.direction(), values == null ? "" : values.control(), values == null ? "" : values.property()) diff --git a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java index b8831a8..26d4069 100644 --- a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java +++ b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java @@ -727,14 +727,105 @@ public final class ProjectionHostChecks { java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document())) .equals(changed), "Repeat save changed source"); - click(pane, "Validate Repeat bindings"); + selectRepeat(pane, "otherVisits"); + click(pane, "Discover / Validate Repeat"); await( () -> fx( () -> ((Label) pane.lookup("#repeat-status")) .getText() - .contains("Collection and Component compatibility"))); + .contains("Compatibility uses declarations"))); + await(() -> fx(() -> !((ComboBox) pane.lookup("#repeat-components")).getItems().isEmpty())); + fx( + () -> { + var choices = + (ComboBox) + pane.lookup("#repeat-components"); + check( + choices.getItems().stream() + .anyMatch( + c -> + c.name().equals("fixture.components.AddressForm") + && c.status().equals("COMPATIBLE")), + "Compatible Component choice absent"); + check( + choices.getItems().stream() + .anyMatch( + c -> + c.name().equals("fixture.components.WrongRepeatCard") + && c.status().equals("INCOMPATIBLE")), + "Incompatible Component not explained"); + var panes = + (ComboBox) + pane.lookup("#repeat-panes"); + check( + panes.getItems().stream() + .anyMatch(c -> c.name().equals("action") && c.status().equals("INCOMPATIBLE")), + "Non-Pane target accepted"); + check( + ((TextField) pane.lookup("#repeat-component")) + .getText() + .equals("fixture.components.VisitRow"), + "Discovery replaced explicit Component"); + var collections = + (ComboBox) + pane.lookup("#repeat-collections"); + collections.setValue( + collections.getItems().stream() + .filter(c -> c.name().equals("visits")) + .findFirst() + .orElseThrow()); + return null; + }); + await(() -> fx(() -> !((ComboBox) pane.lookup("#repeat-components")).getItems().isEmpty())); + check(documentText(document).equals(changed), "Repeat discovery changed source"); + fx( + () -> { + var choices = + (ComboBox) + pane.lookup("#repeat-components"); + choices.setValue( + choices.getItems().stream() + .filter(c -> c.name().equals("fixture.components.WrongRepeatCard")) + .findFirst() + .orElseThrow()); + return null; + }); + check(documentText(document).equals(changed), "Component choice applied without review"); + click(pane, "Apply Repeat binding"); + String mismatched = + changed.replace( + "component=\"fixture.components.VisitRow\"", + "component=\"fixture.components.WrongRepeatCard\""); + await(() -> documentText(document).equals(mismatched)); + await(() -> fx(() -> source(pane).getText().equals(mismatched))); + document + .save(document.snapshot().toCompletableFuture().get().revision()) + .toCompletableFuture() + .get(); + click(pane, "Render drafts"); + await( + () -> + fx( + () -> + ((Label) pane.lookup("#repeat-status")) + .getText() + .contains("incompatible with Component Model"))); + await( + () -> + fx( + () -> + pane.lookupAll(".label").stream() + .anyMatch( + n -> + n instanceof Label l + && l.getText() != null + && l.getText().startsWith("Preview blocked:")))); + repeatHistory(pane, document, changed, mismatched); + click(pane, "Undo"); + await(() -> documentText(document).equals(changed)); + await(() -> fx(() -> source(pane).getText().equals(changed))); click(pane, "Render drafts"); await( () -> @@ -773,6 +864,7 @@ public final class ProjectionHostChecks { click(pane, "Apply Repeat binding"); String invalid = replaced.replace("collection=\"visits\"/>", "collection=\"invalid path\"/>"); await(() -> documentText(document).equals(invalid)); + await(() -> fx(() -> source(pane).getText().equals(invalid))); document .save(document.snapshot().toCompletableFuture().get().revision()) .toCompletableFuture() diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java index cd44e8d..b1bb523 100644 --- a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/BindingDiscovery.java @@ -23,6 +23,8 @@ public final class BindingDiscovery { private record TargetType(Class type, boolean writable) {} + private final Map> controlTypes = new HashMap<>(); + private RepeatDiscovery repeats; private final Map targetTypes = new HashMap<>(); private BindingDiscovery(ClassLoader loader) { @@ -63,7 +65,7 @@ public final class BindingDiscovery { return new BindingDiscovery(loader).inspect(fxml, path, direction, control, property, resource); } - private static Document parse(String xml) throws Exception { + static Document parse(String xml) throws Exception { if (xml.length() > 512 * 1024) throw new IOException("FXML metadata input exceeds limit"); var f = javax.xml.parsers.DocumentBuilderFactory.newInstance(); f.setNamespaceAware(true); @@ -75,7 +77,7 @@ public final class BindingDiscovery { return f.newDocumentBuilder().parse(new org.xml.sax.InputSource(new StringReader(xml))); } - private static String model(Document doc) { + static String model(Document doc) { String model = ""; for (Node n = doc.getFirstChild(); n != null; n = n.getNextSibling()) if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("neitzel-model")) { @@ -238,6 +240,7 @@ public final class BindingDiscovery { && owner.controller != null ? owner.controller : resolve(e, imports); + controlTypes.put(id, control); String componentModel = ""; var componentResource = control.getResource(control.getSimpleName() + ".fxml"); if (componentResource != null) @@ -321,6 +324,7 @@ public final class BindingDiscovery { bypassesConverter(selected) ? List.of() : converters.candidates(source, chosen == null ? null : chosen.type(), direction); + repeats = new RepeatDiscovery(loader, attributes, owner, incompleteModel, controlTypes, ids); return new BindingMetadata( model, selected, @@ -332,7 +336,10 @@ public final class BindingDiscovery { validate(doc, imports, ids, converters), owner.controller == null ? "" : owner.controller.getName(), owner.viewModel == null ? "" : owner.viewModel.getName(), - owner.methods(chosen == null ? null : chosen.type())); + owner.methods(chosen == null ? null : chosen.type()), + repeats.collections(), + repeats.candidates(selected), + repeats.panes()); } private void selectOwner(Document doc, List imports, String resource, Class model) @@ -412,8 +419,9 @@ public final class BindingDiscovery { var nodes = doc.getElementsByTagName("*"); for (int i = 0; i < nodes.getLength(); i++) { var e = (Element) nodes.item(i); - if (!library(e, "DirectBinding", imports) && !library(e, "ComputedBinding", imports)) - continue; + if (!library(e, "DirectBinding", imports) + && !library(e, "ComputedBinding", imports) + && !library(e, "Repeat", imports)) continue; Node parent = e.getParentNode(); if (parent instanceof Element p && p.getTagName().equals("elements")) parent = p.getParentNode(); @@ -423,6 +431,22 @@ public final class BindingDiscovery { || !FX.equals(define.getNamespaceURI()) || !"define".equals(define.getLocalName()) || define.getParentNode() != doc.getDocumentElement()) continue; + if (library(e, "Repeat", imports)) { + String pane = e.getAttribute("pane"); + boolean nested = false; + for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling()) + if (n instanceof Element) nested = true; + if (nested) + problems.add( + new Problem(pane, "children", "WARNING", "Nested Repeat requires Source review")); + else { + if (!occupied.add("Repeat:" + pane)) + problems.add(new Problem(pane, "children", "ERROR", "Duplicate Repeat pane")); + problems.addAll( + repeats.validate(pane, e.getAttribute("collection"), e.getAttribute("component"))); + } + continue; + } String control = e.getAttribute("control"), property = e.getAttribute("property"), key = control + "." + property; @@ -555,7 +579,7 @@ public final class BindingDiscovery { return candidates.iterator().next(); } - private static Class propertyType(Type type, Map, Type> inherited) { + static Class propertyType(Type type, Map, Type> inherited) { Class raw; var bindings = new HashMap<>(inherited); if (type instanceof ParameterizedType p && p.getRawType() instanceof Class c) { diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/RepeatDiscovery.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/RepeatDiscovery.java new file mode 100644 index 0000000..0c3f605 --- /dev/null +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/RepeatDiscovery.java @@ -0,0 +1,350 @@ +package de.neitzel.fxdesigner.target; + +import de.neitzel.fxdesigner.protocol.BindingMetadata.*; +import java.io.*; +import java.lang.reflect.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.jar.JarFile; +import javafx.beans.property.Property; +import javafx.beans.value.ObservableValue; +import javafx.collections.ObservableList; +import javafx.scene.Parent; +import javafx.scene.layout.Pane; +import org.w3c.dom.*; + +/** Signature/resource-only Repeat checks. Never invokes an application accessor or constructor. */ +final class RepeatDiscovery { + private final ClassLoader loader; + private final Map collections = new TreeMap<>(); + private final Map> controls; + private final Set ids; + private final boolean incomplete; + private final Set nonCollections = new HashSet<>(); + private final Map components = new TreeMap<>(); + private final SortedSet names = new TreeSet<>(); + + private record ComponentInfo(Class model, Class property, String declared, String error) {} + + RepeatDiscovery( + ClassLoader loader, + List attributes, + ControllerMetadata owner, + boolean incomplete, + Map> controls, + Set ids) + throws Exception { + this.loader = loader; + this.controls = controls; + this.ids = ids; + this.incomplete = incomplete; + if (!owner.dynamicProperties && !owner.unknownController) + for (var a : attributes) + if (Set.of("VALUE", "BUILTIN", "COMPLEX").contains(a.kind())) nonCollections.add(a.path()); + for (var a : attributes) { + if (!a.kind().equals("COLLECTION") + && !a.kind().equals("VIEWMODEL") + && !a.kind().equals("UNRESOLVED")) continue; + Class raw; + try { + raw = a.type().equals("?") ? Object.class : load(a.type()); + } catch (ClassNotFoundException primitive) { + continue; + } + String element = a.elementType(); + boolean wrapped = a.kind().equals("COLLECTION"); + if (!wrapped && owner.viewModel != null) { + try { + Method m = owner.viewModel.getMethod(a.path() + "Property"); + Type value = argument(m.getGenericReturnType(), ObservableValue.class); + element = element(argument(value, Collection.class)); + } catch (NoSuchMethodException ignored) { + } + } + if (raw != Object.class && !Collection.class.isAssignableFrom(raw)) { + if (!owner.dynamicProperties && !owner.unknownController) nonCollections.add(a.path()); + continue; + } + if (owner.dynamicProperties || owner.unknownController) element = ""; + boolean unknown = + owner.dynamicProperties + || owner.unknownController + || element.isEmpty() + || element.equals(Object.class.getName()) + || (!wrapped && !ObservableList.class.isAssignableFrom(raw)); + collections.put( + a.path(), + new RepeatOption( + a.path(), + element.isEmpty() ? "?" : element, + unknown ? "UNVERIFIED" : "COMPATIBLE", + unknown + ? "Element type or ObservableList backing is unresolved" + : "Declared collection element type; runtime values may be null")); + } + indexResources(); + } + + private Class load(String name) throws ClassNotFoundException { + return Class.forName(name, false, loader); + } + + List collections() { + return List.copyOf(collections.values()); + } + + List panes() { + return ids.stream() + .sorted() + .map( + id -> { + Class type = controls.get(id); + return new RepeatOption( + id, + type == null ? "?" : type.getName(), + type == null + ? "UNVERIFIED" + : Pane.class.isAssignableFrom(type) ? "COMPATIBLE" : "INCOMPATIBLE", + type == null + ? "Pane type unavailable" + : Pane.class.isAssignableFrom(type) + ? "JavaFX Pane" + : "Repeat requires a JavaFX Pane"); + }) + .toList(); + } + + List candidates(String path) { + return names.stream().map(n -> component(n, path)).toList(); + } + + RepeatOption collection(String path) { + if (nonCollections.contains(path)) + return new RepeatOption(path, "?", "INCOMPATIBLE", "Repeat requires a collection: " + path); + var c = collections.get(path); + return c != null + ? c + : new RepeatOption( + path, + "?", + incomplete ? "UNVERIFIED" : "INCOMPATIBLE", + incomplete ? "Collection path unresolved" : "No discoverable collection: " + path); + } + + RepeatOption component(String name, String path) { + ComponentInfo info = components.computeIfAbsent(name, this::readComponent); + if (info.error() != null) + return new RepeatOption(name, info.declared(), "INCOMPATIBLE", info.error()); + var source = collection(path); + if (source.status().equals("INCOMPATIBLE")) + return new RepeatOption( + name, info.declared(), "UNVERIFIED", "Choose a collection to compare element types"); + Class element; + try { + element = source.type().equals("?") ? null : load(source.type()); + } catch (ClassNotFoundException e) { + element = null; + } + if (element != null && element != Object.class) { + for (var constraint : Arrays.asList(info.model(), info.property())) + if (constraint != null + && constraint != Object.class + && !constraint.isAssignableFrom(element)) + return new RepeatOption( + name, + info.declared(), + "INCOMPATIBLE", + "Collection element " + + element.getName() + + " is incompatible with Component Model " + + constraint.getName()); + } + boolean verified = + source.status().equals("COMPATIBLE") + && element != null + && element != Object.class + && (info.model() != null || info.property() != null && info.property() != Object.class); + return new RepeatOption( + name, + info.declared(), + verified ? "COMPATIBLE" : "UNVERIFIED", + verified + ? "Declared element fits Component Model; construction remains a runtime check" + : "Collection or Component Model type is unresolved"); + } + + List validate(String pane, String path, String component) { + var problems = new ArrayList(); + if (!ids.contains(pane)) + problems.add(new Problem(pane, "children", "ERROR", "Unknown Repeat pane: " + pane)); + else if (!controls.containsKey(pane)) + problems.add(new Problem(pane, "children", "WARNING", "Repeat pane type unavailable")); + else if (!Pane.class.isAssignableFrom(controls.get(pane))) + problems.add(new Problem(pane, "children", "ERROR", "Repeat requires a JavaFX Pane")); + var c = collection(path); + if (!c.status().equals("COMPATIBLE")) + problems.add( + new Problem( + pane, + "children", + c.status().equals("INCOMPATIBLE") ? "ERROR" : "WARNING", + c.reason())); + var target = component(component, path); + if (!target.status().equals("COMPATIBLE")) + problems.add( + new Problem( + pane, + "children", + target.status().equals("INCOMPATIBLE") ? "ERROR" : "WARNING", + target.reason())); + return problems; + } + + private ComponentInfo readComponent(String name) { + String declared = "?"; + try { + Class type = load(name); + if (!Parent.class.isAssignableFrom(type) + || !Modifier.isPublic(type.getModifiers()) + || Modifier.isAbstract(type.getModifiers())) + throw new IllegalArgumentException( + "Repeat Component must be a public concrete JavaFX Parent"); + Method accessor = type.getMethod("modelProperty"); + if (!Property.class.isAssignableFrom(accessor.getReturnType()) + || !ControllerMetadata.accessible(accessor.getDeclaringClass(), true)) + throw new IllegalArgumentException("Component needs an accessible writable modelProperty"); + Class property = BindingDiscovery.propertyType(accessor.getGenericReturnType(), Map.of()); + var resource = type.getResource(type.getSimpleName() + ".fxml"); + if (resource == null) + throw new IllegalArgumentException("Component FXML unavailable: " + name); + Document doc; + try (var in = resource.openStream()) { + byte[] bytes = in.readNBytes(512 * 1024 + 1); + if (bytes.length > 512 * 1024) throw new IOException("Component FXML exceeds limit"); + doc = BindingDiscovery.parse(new String(bytes, StandardCharsets.UTF_8)); + } + Element root = doc.getDocumentElement(); + if (!"http://javafx.com/fxml/1".equals(root.getNamespaceURI()) + || !"root".equals(root.getLocalName())) + throw new IllegalArgumentException("Component FXML requires fx:root"); + if (root.hasAttributeNS("http://javafx.com/fxml/1", "controller")) + throw new IllegalArgumentException("Component FXML must not override its Controller"); + String base = root.getAttribute("type"); + var roots = new HashSet>(); + if (base.contains(".")) roots.add(load(base)); + else + for (Node n = doc.getFirstChild(); n != null; n = n.getNextSibling()) + if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import")) { + String imp = pi.getData().trim(); + try { + if (imp.endsWith(".*")) roots.add(load(imp.substring(0, imp.length() - 1) + base)); + else if (imp.endsWith("." + base)) roots.add(load(imp)); + } catch (ClassNotFoundException ignored) { + } + } + if (roots.size() != 1 || !roots.iterator().next().isAssignableFrom(type)) + throw new IllegalArgumentException("Component fx:root type is incompatible or unresolved"); + declared = BindingDiscovery.model(doc); + Class model = declared.isEmpty() ? null : load(declared); + if (declared.isEmpty()) declared = property == null ? "?" : property.getName(); + return new ComponentInfo(model, property, declared, null); + } catch (Exception | LinkageError e) { + return new ComponentInfo( + null, null, declared, "Component unavailable: " + name + ": " + e.getMessage()); + } + } + + private void indexResources() throws IOException { + var entries = new LinkedHashSet(); + for (String key : List.of("java.class.path", "jdk.module.path")) + entries.addAll(Arrays.asList(System.getProperty(key, "").split(File.pathSeparator))); + int count = 0; + for (String entry : entries) { + if (entry.isEmpty()) continue; + Path path = Path.of(entry); + if (Files.isDirectory(path)) { + try (var files = Files.walk(path)) { + var it = files.filter(p -> p.toString().endsWith(".fxml")).iterator(); + while (it.hasNext()) { + if (++count > 4096) throw new IOException("Too many Component resources"); + index(path.relativize(it.next()).toString().replace(File.separatorChar, '/')); + } + } + } else if (Files.isRegularFile(path) && entry.endsWith(".jar")) { + try (var jar = new JarFile(path.toFile(), false, JarFile.OPEN_READ, Runtime.version())) { + var it = jar.versionedStream().filter(e -> e.getName().endsWith(".fxml")).iterator(); + while (it.hasNext()) { + if (++count > 4096) throw new IOException("Too many Component resources"); + index(it.next().getName()); + } + } + } + } + } + + private void index(String resource) throws IOException { + if (resource.startsWith("META-INF/")) return; + String name = resource.substring(0, resource.length() - 5).replace('/', '.'); + try { + Class type = load(name); + if (Parent.class.isAssignableFrom(type)) names.add(name); + if (names.size() > 256) throw new IOException("Too many Repeat Components"); + } catch (ClassNotFoundException | LinkageError ignored) { + } + } + + private record Parameterized(Type raw, Type[] arguments) implements ParameterizedType { + public Type getRawType() { + return raw; + } + + public Type[] getActualTypeArguments() { + return arguments.clone(); + } + + public Type getOwnerType() { + return null; + } + } + + private static Type substitute(Type t, Map, Type> vars) { + if (t instanceof TypeVariable v) return vars.getOrDefault(v, v); + if (t instanceof ParameterizedType p) + return new Parameterized( + p.getRawType(), + Arrays.stream(p.getActualTypeArguments()) + .map(a -> substitute(a, vars)) + .toArray(Type[]::new)); + return t; + } + + private static Type argument(Type type, Class target) { + if (type == null) return null; + Class raw; + var vars = new HashMap, Type>(); + if (type instanceof ParameterizedType p && p.getRawType() instanceof Class c) { + raw = c; + for (int i = 0; i < c.getTypeParameters().length; i++) + vars.put(c.getTypeParameters()[i], p.getActualTypeArguments()[i]); + } else if (type instanceof Class c) raw = c; + else return null; + if (raw == target) return vars.get(raw.getTypeParameters()[0]); + for (Type parent : raw.getGenericInterfaces()) { + Type value = argument(substitute(parent, vars), target); + if (value != null) return value; + } + return raw.getGenericSuperclass() == null + ? null + : argument(substitute(raw.getGenericSuperclass(), vars), target); + } + + private static String element(Type t) { + if (t instanceof Class c) return c.getName(); + if (t instanceof WildcardType w + && w.getLowerBounds().length == 0 + && w.getUpperBounds().length == 1 + && w.getUpperBounds()[0] instanceof Class c) return c.getName(); + return ""; + } +} diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java index d8d0df6..9c08837 100644 --- a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java @@ -92,7 +92,7 @@ public final class WorkerMain { new Version(1, 0), new Version(1, 0), new Version(1, 0), - Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v3"), + Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1", "bindings-v4"), Set.of())); var welcome = FrameCodec.read(socket.getInputStream()); if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1) diff --git a/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/RepeatDiscoveryTest.java b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/RepeatDiscoveryTest.java new file mode 100644 index 0000000..5167851 --- /dev/null +++ b/designer-target-tools/src/test/java/de/neitzel/fxdesigner/target/RepeatDiscoveryTest.java @@ -0,0 +1,170 @@ +package de.neitzel.fxdesigner.target; + +import static org.junit.jupiter.api.Assertions.*; + +import de.neitzel.fx.view.*; +import java.util.*; +import javafx.beans.property.*; +import javafx.scene.layout.VBox; +import org.junit.jupiter.api.Test; + +class RepeatDiscoveryTest { + public static class Item {} + + public static class Model { + static { + System.setProperty("repeat.model.initialized", "yes"); + } + + public List getItems() { + throw new AssertionError("getter"); + } + + public void setItems(List v) {} + + public List getRaw() { + throw new AssertionError("getter"); + } + + public void setRaw(List v) {} + + public String getName() { + throw new AssertionError("getter"); + } + + public void setName(String v) {} + } + + public static class Replacement { + public Replacement(Model m) { + throw new AssertionError("constructor"); + } + + public ListProperty itemsProperty() { + throw new AssertionError("accessor"); + } + + public ObjectProperty> uncertainProperty() { + throw new AssertionError("accessor"); + } + + public ObjectProperty nameProperty() { + throw new AssertionError("accessor"); + } + } + + @ViewModelClass(Replacement.class) + public static class Controller {} + + public static class Good extends VBox { + static { + System.setProperty("repeat.component.initialized", "yes"); + } + + public ObjectProperty modelProperty() { + throw new AssertionError("accessor"); + } + } + + public static class Wrong extends VBox { + public ObjectProperty modelProperty() { + throw new AssertionError("accessor"); + } + } + + public static class Unknown extends VBox { + public ObjectProperty modelProperty() { + throw new AssertionError("accessor"); + } + } + + public static class ReadOnly extends VBox { + public ReadOnlyObjectProperty modelProperty() { + throw new AssertionError("accessor"); + } + } + + private String xml(String collection, String component, String pane, String controller) { + return "" + + "" + + ""; + } + + private de.neitzel.fxdesigner.protocol.BindingMetadata inspect( + String path, Class type, String pane, String controller) throws Exception { + return BindingDiscovery.inspect( + xml(path, type.getName(), pane, controller), path, "view", getClass().getClassLoader()); + } + + @Test + void acceptsDeclaredTypesWithoutInitializingApplicationCode() throws Exception { + var m = inspect("items", Good.class, "rows", ""); + assertTrue(m.problems().isEmpty(), m.problems().toString()); + assertTrue( + m.collections().stream() + .anyMatch( + c -> + c.name().equals("items") + && c.type().equals(Item.class.getName()) + && c.status().equals("COMPATIBLE"))); + assertTrue( + m.panes().stream() + .anyMatch(p -> p.name().equals("label") && p.status().equals("INCOMPATIBLE"))); + assertNull(System.getProperty("repeat.model.initialized")); + assertNull(System.getProperty("repeat.component.initialized")); + } + + @Test + void rejectsWrongModelPaneAndMissingCollection() throws Exception { + assertTrue( + inspect("items", Wrong.class, "rows", "").problems().stream() + .anyMatch(p -> p.severity().equals("ERROR") && p.message().contains("incompatible"))); + assertTrue( + inspect("items", Good.class, "label", "").problems().stream() + .anyMatch(p -> p.message().contains("JavaFX Pane"))); + assertTrue( + inspect("name", Good.class, "rows", "").problems().stream() + .anyMatch(p -> p.severity().equals("ERROR") && p.message().contains("collection"))); + assertTrue( + inspect("absent", Good.class, "rows", "").problems().stream() + .anyMatch(p -> p.severity().equals("ERROR"))); + assertTrue( + inspect("items", ReadOnly.class, "rows", "").problems().stream() + .anyMatch(p -> p.message().contains("modelProperty"))); + } + + @Test + void keepsUnknownElementAndComponentModelsUnverified() throws Exception { + for (var m : + List.of( + inspect("raw", Good.class, "rows", ""), inspect("items", Unknown.class, "rows", ""))) { + assertTrue(m.problems().stream().anyMatch(p -> p.severity().equals("WARNING"))); + assertTrue( + m.problems().stream().noneMatch(p -> p.severity().equals("ERROR")), + m.problems().toString()); + } + } + + @Test + void discoversReplacementObservableCollectionsAndWarnsForPlainListBacking() throws Exception { + var m = inspect("items", Good.class, "rows", Controller.class.getName()); + assertTrue(m.problems().isEmpty(), m.problems().toString()); + assertTrue( + m.collections().stream() + .anyMatch(c -> c.name().equals("items") && c.status().equals("COMPATIBLE"))); + assertTrue(m.collections().stream().noneMatch(c -> c.name().equals("raw"))); + var uncertain = inspect("uncertain", Good.class, "rows", Controller.class.getName()); + assertTrue(uncertain.problems().stream().anyMatch(p -> p.severity().equals("WARNING"))); + } +} diff --git a/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/Good.fxml b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/Good.fxml new file mode 100644 index 0000000..0dfdda2 --- /dev/null +++ b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/Good.fxml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/ReadOnly.fxml b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/ReadOnly.fxml new file mode 100644 index 0000000..0dfdda2 --- /dev/null +++ b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/ReadOnly.fxml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/Unknown.fxml b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/Unknown.fxml new file mode 100644 index 0000000..0dfdda2 --- /dev/null +++ b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/Unknown.fxml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/Wrong.fxml b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/Wrong.fxml new file mode 100644 index 0000000..0dfdda2 --- /dev/null +++ b/designer-target-tools/src/test/resources/de/neitzel/fxdesigner/target/Wrong.fxml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/binding-discovery.md b/docs/binding-discovery.md index 8eac5b8..b14858e 100644 --- a/docs/binding-discovery.md +++ b/docs/binding-discovery.md @@ -12,7 +12,7 @@ For Person the browser exposes `name`, `rank`, `home`, `home.street`, and `visit ## Metadata boundary -The fresh target worker loads classes without initialization and inspects signatures. It never constructs Models, Controllers, providers, controls or Converters, never invokes getters/property accessors, and never starts JavaFX. Application types stay outside both hosts. The independently negotiated `bindings-v3` capability adds the BINDINGS operation; it uses bounded UTF-8 metadata, build/context identity and document revisions, with zero image dimensions. Existing wire operation ordinals remain unchanged. +The fresh target worker loads classes without initialization and inspects signatures. It never constructs Models, Controllers, providers, controls or Converters, never invokes getters/property accessors, and never starts JavaFX. Application types stay outside both hosts. The independently negotiated `bindings-v4` capability adds the BINDINGS operation; it uses bounded UTF-8 metadata, build/context identity and document revisions, with zero image dimensions. Existing wire operation ordinals remain unchanged. Model fallback follows NeitzelLib's public read/write JavaBean Attribute rules and simple/complex/collection classification. Nested paths expand to depth four with cycle diagnostics; collection paths expose declared element types without pretending a collection is a single nested Model. Explicit custom BeanInfo is not executed: types requiring it report a generated-metadata diagnostic. Custom/replacement ViewModels, built-in message properties and Computed methods now follow the [signature-based selection rules](viewmodel-computed-validation.md). Generated metadata and arbitrary dynamic property APIs remain unresolved. diff --git a/docs/converter-validation.md b/docs/converter-validation.md index ad2ba9e..654d270 100644 --- a/docs/converter-validation.md +++ b/docs/converter-validation.md @@ -27,7 +27,7 @@ The chooser distinguishes compatible, incompatible, unverified and invalid candi The worker indexes class headers in captured classpath/module-path directories and JARs, respecting multi-release JAR selection, then loads Converter descendants without initialization. It reads annotations and method signatures without constructing Converters or invoking application methods. The index is bounded to 100,000 classes and 256 candidates; the normal worker deadline and cancellation apply. Application classes never enter a host. -The subsequent [ViewModel/Computed slice](viewmodel-computed-validation.md) now uses `bindings-v3` and metadata format 3, adding candidate and document-problem records to the existing BINDINGS operation without changing its wire ordinal. Older binding capabilities are not silently interpreted as this format. The UTF-8 payload, metadata counts, request identity, build identity and document-revision checks remain bounded. +The subsequent [ViewModel/Computed slice](viewmodel-computed-validation.md) now uses `bindings-v4` and metadata format 4, adding candidate and document-problem records to the existing BINDINGS operation without changing its wire ordinal. Older binding capabilities are not silently interpreted as this format. The UTF-8 payload, metadata counts, request identity, build identity and document-revision checks remain bounded. The validator covers attribute-form DirectBindings in root BindingLists: discoverable Model paths, control IDs, JavaFX property existence/writability, plain type compatibility, explicit Converter availability/capabilities and duplicate Direct/Computed targets. Known violations are errors. Unresolved types, incomplete/custom Model metadata, missing Model declarations and nested binding syntax produce warnings. Custom/replacement ViewModel signatures and Computed methods are now checked by the [next slice](viewmodel-computed-validation.md). Generated metadata, collection semantics, nested Component draft metadata, dependency injection availability and runtime method behavior remain incomplete. A warning does not certify that preview will succeed. Complete Problems-view integration and broader binding authoring remain later work. diff --git a/docs/repeat-binding-inspector.md b/docs/repeat-binding-inspector.md index 187734e..b6a79f6 100644 --- a/docs/repeat-binding-inspector.md +++ b/docs/repeat-binding-inspector.md @@ -14,9 +14,9 @@ A second Repeat targeting the same pane requires **Replace conflicting bindings* ## Validation boundary -Structural diagnostics cover missing/ambiguous pane identifiers, duplicate Repeat panes, and missing/malformed Component class or collection names. Known structural errors block new previews; invalid drafts remain saveable and undoable. **Validate Repeat bindings** reports these checks and existing target-side binding diagnostics for the current source. Validation does not apply pending form values. +Structural diagnostics cover missing/ambiguous pane identifiers, duplicate Repeat panes, and missing/malformed Component class or collection names. Known structural errors block new previews; invalid drafts remain saveable and undoable. **Discover / Validate Repeat** reports structural and [target-side compatibility checks](repeat-compatibility.md) for the current source. Validation does not apply pending form values. -This slice accepts typed field values. A collection/Component chooser and complete preflight compatibility discovery are later work. The pane's actual Java type, collection existence and ObservableList value, element/Component Model compatibility, Component loading and injection still require target-runtime loading. The inspector explicitly distinguishes structural validation from those remaining runtime checks. Generated metadata, Table/Column authoring, structural reference refactoring and complete IDE Problems integration also remain open. +The [collection/Component discovery slice](repeat-compatibility.md) now adds choices and declared-type validation in both hosts. Unknown metadata remains a warning; actual values, Component construction and injection remain runtime checks. Generated metadata, Table/Column authoring, structural reference refactoring and complete IDE Problems integration remain open. ## Automated qualification diff --git a/docs/repeat-compatibility.md b/docs/repeat-compatibility.md new file mode 100644 index 0000000..33fa33b --- /dev/null +++ b/docs/repeat-compatibility.md @@ -0,0 +1,37 @@ +# Repeat collection and Component discovery + +In **Bindings → Repeat bindings**, use **Discover / Validate Repeat** after Build and Refresh. The shared inspector lists collections with their element types, pane targets, and Component candidates with compatibility explanations. Choosing a collection refreshes the Component comparisons. Choosing a pane or Component only fills the form; **Apply Repeat binding** remains the source-changing action. Discovery preserves explicitly entered Component names and never silently selects a unique candidate. + +The same worker-side checks validate saved or unsaved Repeat declarations before new previews. Known errors block preview while Save and ordinary host Undo/Redo remain available. Unknown types produce warnings and allow preview. Form suggestions are checked against the requested collection; document diagnostics describe the existing source, not unapplied field changes. Source/build invalidation clears Repeat choices, and stale responses cannot populate a changed form or document. + +## Runtime-aligned metadata + +Generic ViewModel Model collections retain the existing nested Attribute paths and generic element metadata. NeitzelLib wraps Model collections as observable lists. Custom and replacement ViewModels use their public JavaFX Property accessor signatures; `ListProperty` and `Property>` provide element information without invoking accessors. A plain `Property>` cannot prove its value is observable and remains unverified. Raw collections, unresolved type variables, dynamic property resolvers, missing Model context and absent Component Model constraints are also unverified. Known non-collection properties are rejected. + +Component discovery scans conventional FXML resource names on the target classpath/module path, then loads associated classes **without initialization**. It checks public concrete JavaFX Parent classes, writable accessible `modelProperty`, available `ClassName.fxml`, `fx:root`, its base type and the absence of a conflicting Controller. Collection element types are compared with both the Component's `neitzel-model` declaration and its typed Model property. Missing resources/classes, wrong pane types, non-collection paths and known Model mismatches are errors. Pane choices include incompatible controls with explanations rather than hiding why they cannot be used. + +The scan uses the target resource loader, including captured draft overlays for existing Component resources. It is bounded to 4,096 scanned FXML entries and 256 candidates. Component FXML parsing is secure and size-limited. No application constructor, getter, accessor, Controller method, provider or static initializer is executed by discovery. Application types never enter the host UI. + +## Boundaries + +Compatibility means declared type compatibility, not guaranteed runtime success. Injection configuration, constructor/accessor bodies, null collection values, null elements, dynamic list contents and Component binding behavior remain runtime checks. This slice does not alter NeitzelLib's collection-change semantics. Generated metadata, arbitrary generic substitution across a selected ViewModel's inheritance, BeanInfo execution and cross-View incidental ViewModel selection remain outside the signature fallback. + +Named modules must expose Component resources to the discovery worker and reflective Model access to NeitzelLib; unavailable metadata is reported rather than assumed compatible. + +Candidate enumeration follows conventional `package/ClassName.fxml` resource names; unusual/nested class naming may require typing the class explicitly and validating the declaration. Newly created classes still require Build and Refresh. Table/Column authoring, structural reference refactoring and complete IDE Problems integration remain later work. + +## Protocol and automation + +Binding metadata format 4 and the negotiated `bindings-v4` capability add bounded collection, Component and pane options. Old metadata capabilities are rejected. Existing build, draft and worker identity checks remain in effect; no host-specific application reflection was added. + +Unit tests cover matching and mismatching types, wrong pane targets, missing/non-collection paths, raw element types, writable Model access, replacement ViewModel collections, uncertain plain-List backing, non-initialization and metadata round trips. Real classpath and module-path fixtures qualify Component resource discovery and mismatch diagnostics. The shared live host scenario chooses a collection, checks compatible/incompatible options, preserves explicit choices and source until Apply, saves a mismatched Component, verifies preview blocking and exact Undo/Redo, then renders the corrected draft. Existing inspector and lifecycle scenarios remain enabled. No manual testing is required; previously skipped checks remain skipped. + +Run the bootstrap and registered IntelliJ checks documented in the [README](../README.md). + +## Qualification — 2026-11-13 + +The full bootstrap gate passed: 103 unit/adapter tests, 36 target processes with 498 assertions, both native standalone modes, 48 worker lifecycle cycles, connected host checks and plugin packaging. Registered IntelliJ classpath and module-path runs also passed the shared inspector scenarios, exclusive JavaFX 26 native-library checks and worker shutdown. + +Earlier registered IntelliJ attempts encountered one initial-render timeout and two immediate saved-file assertions (one Repeat and one pre-existing DirectBinding scenario). The files inspected after failure contained the requested invalid drafts. Waiting for the shared Source view before the Repeat saves did not explain the DirectBinding failure. A subsequent complete registered run passed with unchanged production code and unchanged file-content assertions; temporary diagnostics were removed. The intermittent render/save failures are **not claimed fixed** and remain automation-reliability follow-ups. No speculative save or rendering change was included. + +Local run evidence: `/tmp/repeat-compatibility-bootstrap-final.log`, `/tmp/repeat-save-diagnostic.log`, and `target/ide-workflow/{classpath,modulepath}.json`. These generated artifacts are not repository inputs. Previously skipped human checks remain skipped. diff --git a/docs/repository-bootstrap-plan.md b/docs/repository-bootstrap-plan.md index baa5239..c5b3be0 100644 --- a/docs/repository-bootstrap-plan.md +++ b/docs/repository-bootstrap-plan.md @@ -120,4 +120,8 @@ The [Computed authoring slice](computed-binding-inspector.md) adds create/change ## Shared Repeat-binding inspector -The [Repeat authoring slice](repeat-binding-inspector.md) adds create/change/remove, explicit duplicate-pane replacement, structural diagnostics and normal host Undo/Redo in both hosts. Collection/Component compatibility discovery and Table/Column authoring remain subsequent work, alongside generated metadata, reference refactoring and Problems integration. +The [Repeat authoring slice](repeat-binding-inspector.md) adds create/change/remove, explicit duplicate-pane replacement, structural diagnostics and normal host Undo/Redo in both hosts. Collection/Component compatibility discovery is covered by the next completed slice below. Table/Column authoring, generated metadata, reference refactoring and Problems integration remain subsequent work. + +## Repeat collection and Component compatibility + +The [shared Repeat discovery slice](repeat-compatibility.md) adds collection, pane and Component choices, non-initializing target-side type comparisons, source-preserving explicit selections and document diagnostics with preview gating. Runtime construction and values remain preview checks. Table/Column authoring, generated metadata, reference refactoring and Problems integration remain open. diff --git a/docs/viewmodel-computed-validation.md b/docs/viewmodel-computed-validation.md index c8d2cdb..a173b3c 100644 --- a/docs/viewmodel-computed-validation.md +++ b/docs/viewmodel-computed-validation.md @@ -20,7 +20,7 @@ Computed validation still runs when a Model declaration is missing: that limits ## Boundaries -Application classes stay in the target worker and are loaded without initialization. The `bindings-v3` capability and metadata format 3 add Controller/ViewModel identity and Computed signatures, plus the captured resource on requests. Old metadata capabilities are rejected rather than silently decoded. Existing protocol identities, payload limits, path/target limits and worker cancellation apply; the method inventory is bounded to 256 entries. +Application classes stay in the target worker and are loaded without initialization. The current `bindings-v4` capability and metadata format 4 retain Controller/ViewModel identity and Computed signatures, plus the captured resource on requests, and add [Repeat options](repeat-compatibility.md). Old metadata capabilities are rejected rather than silently decoded. Existing protocol identities, payload limits, path/target limits and worker cancellation apply; the method inventory is bounded to 256 entries. Generated metadata, custom BeanInfo execution, arbitrary dynamic property resolvers, full generic inference, cross-View first-use conflicts, nested ViewModel selection caused by other Views/Components, collection authoring and dependency-injection viability remain outside this slice. Method bodies, null results, constructor side effects and Computed refresh behavior are runtime concerns; compatible signatures do not prove that application code will succeed. In particular, discovery does not change which additional properties NeitzelLib observes for Computed refresh. Complete IDE Problems integration remains later work. diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/WrongRepeatCard.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/WrongRepeatCard.java new file mode 100644 index 0000000..4ae3a89 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/WrongRepeatCard.java @@ -0,0 +1,11 @@ +package fixture.components; + +public class WrongRepeatCard extends javafx.scene.layout.VBox { + static { + System.setProperty("fixture.repeat.initialized", "yes"); + } + + public javafx.beans.property.ObjectProperty modelProperty() { + throw new AssertionError("Discovery invoked Component"); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/WrongRepeatCard.fxml b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/WrongRepeatCard.fxml new file mode 100644 index 0000000..b166c99 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/WrongRepeatCard.fxml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java b/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java index 22c2edd..99fbc98 100644 --- a/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java +++ b/integration-fixtures/preview/classpath/app/src/test/java/fixture/checks/PreviewChecks.java @@ -178,6 +178,49 @@ public final class PreviewChecks { "fixture.replacement.initialized", "fixture.badgecard.initialized")) check(System.getProperty(key) == null, "Discovery initialized " + key); + try (var in = loader.getResourceAsStream("fixture/components/Full.fxml")) { + String full = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + var repeat = BindingDiscovery.inspect(full, "visits", "view", loader); + check( + repeat.collections().stream() + .anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")), + "Repeat collection absent"); + check( + repeat.components().stream() + .anyMatch( + c -> + c.name().equals("fixture.components.AddressForm") + && c.status().equals("COMPATIBLE")), + "Repeat Component absent"); + check( + repeat.components().stream() + .anyMatch( + c -> + c.name().equals("fixture.components.WrongRepeatCard") + && c.status().equals("INCOMPATIBLE")), + "Wrong Repeat Model accepted"); + check( + repeat.panes().stream() + .anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")), + "Repeat pane absent"); + check(repeat.problems().isEmpty(), "Valid Repeat rejected: " + repeat.problems()); + var bad = + BindingDiscovery.inspect( + full.replace( + "component=\"fixture.components.AddressForm\"", + "component=\"fixture.components.WrongRepeatCard\""), + "visits", + "view", + loader); + check( + bad.problems().stream() + .anyMatch( + p -> p.severity().equals("ERROR") && p.message().contains("incompatible")), + "Repeat mismatch not diagnosed"); + check( + System.getProperty("fixture.repeat.initialized") == null, + "Component discovery initialized class"); + } System.out.println("PASS bindings " + checks); return; } diff --git a/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/WrongRepeatCard.java b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/WrongRepeatCard.java new file mode 100644 index 0000000..4ae3a89 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/java/fixture/components/WrongRepeatCard.java @@ -0,0 +1,11 @@ +package fixture.components; + +public class WrongRepeatCard extends javafx.scene.layout.VBox { + static { + System.setProperty("fixture.repeat.initialized", "yes"); + } + + public javafx.beans.property.ObjectProperty modelProperty() { + throw new AssertionError("Discovery invoked Component"); + } +} diff --git a/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/WrongRepeatCard.fxml b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/WrongRepeatCard.fxml new file mode 100644 index 0000000..b166c99 --- /dev/null +++ b/integration-fixtures/preview/modulepath/app/src/main/resources/fixture/components/WrongRepeatCard.fxml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java b/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java index 22c2edd..99fbc98 100644 --- a/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java +++ b/integration-fixtures/preview/modulepath/checks/src/main/java/fixture/checks/PreviewChecks.java @@ -178,6 +178,49 @@ public final class PreviewChecks { "fixture.replacement.initialized", "fixture.badgecard.initialized")) check(System.getProperty(key) == null, "Discovery initialized " + key); + try (var in = loader.getResourceAsStream("fixture/components/Full.fxml")) { + String full = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + var repeat = BindingDiscovery.inspect(full, "visits", "view", loader); + check( + repeat.collections().stream() + .anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")), + "Repeat collection absent"); + check( + repeat.components().stream() + .anyMatch( + c -> + c.name().equals("fixture.components.AddressForm") + && c.status().equals("COMPATIBLE")), + "Repeat Component absent"); + check( + repeat.components().stream() + .anyMatch( + c -> + c.name().equals("fixture.components.WrongRepeatCard") + && c.status().equals("INCOMPATIBLE")), + "Wrong Repeat Model accepted"); + check( + repeat.panes().stream() + .anyMatch(c -> c.name().equals("visits") && c.status().equals("COMPATIBLE")), + "Repeat pane absent"); + check(repeat.problems().isEmpty(), "Valid Repeat rejected: " + repeat.problems()); + var bad = + BindingDiscovery.inspect( + full.replace( + "component=\"fixture.components.AddressForm\"", + "component=\"fixture.components.WrongRepeatCard\""), + "visits", + "view", + loader); + check( + bad.problems().stream() + .anyMatch( + p -> p.severity().equals("ERROR") && p.message().contains("incompatible")), + "Repeat mismatch not diagnosed"); + check( + System.getProperty("fixture.repeat.initialized") == null, + "Component discovery initialized class"); + } System.out.println("PASS bindings " + checks); return; }