Discover Converter capabilities and validate shared binding drafts

This commit is contained in:
2026-11-13 12:58:08 +01:00
parent 17b1bce704
commit 5a208d2336
35 changed files with 1096 additions and 34 deletions
@@ -115,6 +115,10 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private DesignerWorkflow bindingWorkflow;
private BindingBrowser bindingBrowser;
private DirectBindingEditor bindingEditor;
private String validatedSource, validatingSource;
private boolean validationPreview;
private java.util.List<de.neitzel.fxdesigner.protocol.BindingMetadata.Problem> bindingProblems =
java.util.List.of();
private Tab directBindingTab;
private long bindingRequest;
private boolean interactiveRequested, interactiveRestart;
@@ -182,7 +186,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
if (kit.editor().getFxomDocument() == null) return;
var target = new FXOMFxIdIndex(kit.editor().getFxomDocument()).lookup(id);
if (target != null) kit.editor().getSelection().select(target);
});
},
values -> validateBindings(values, false));
bindingBrowser =
new BindingBrowser(
this::discoverBindings,
@@ -639,6 +644,9 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private void invalidateBindings(String message) {
bindingRequest++;
validatedSource = null;
validatingSource = null;
validationPreview = false;
bindingWorkflow.cancel();
bindingBrowser.invalidate(message);
}
@@ -682,10 +690,66 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
return false;
}
private void validateBindings(DirectBindingDocument.Values values, boolean preview) {
withDraft(
() -> {
if (preview && draft.text().equals(validatingSource)) {
validationPreview = true;
return;
}
long ticket = ++bindingRequest;
var captured = draft;
validatingSource = captured.text();
validationPreview |= preview;
status.setText("Validating binding types in target runtime…");
bindingWorkflow
.bindingDetails(
last,
captured,
values == null ? "" : values.path(),
values == null ? "both" : values.direction(),
values == null ? "" : values.control(),
values == null ? "" : values.property())
.whenComplete(
(metadata, error) ->
fx(
() -> {
if (closed
|| ticket != bindingRequest
|| draft.revision() != captured.revision()) return;
validatingSource = null;
boolean resumePreview = validationPreview;
validationPreview = false;
if (error != null) {
status.setText(
"Binding validation unavailable: " + error.getMessage());
return;
}
validatedSource = captured.text();
bindingProblems = metadata.problems();
bindingEditor.metadata(
metadata,
values,
(last
? "Explicit retained build (outdated)"
: "Current successful build")
+ " · draft revision "
+ captured.revision());
status.setText(
"Binding validation complete: "
+ metadata.problems().size()
+ " diagnostics");
if (resumePreview) render();
}));
});
}
private void discoverBindings(String path, String direction) {
withDraft(
() -> {
long ticket = ++bindingRequest;
validatingSource = null;
validationPreview = false;
bindingBrowser.invalidate("Discovering declared types in target runtime…");
var captured = draft;
bindingWorkflow
@@ -803,6 +867,19 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
withDraft(
() -> {
if (!bindingPreviewAllowed()) return;
if (!draft.text().equals(validatedSource)) {
validateBindings(null, true);
return;
}
if (bindingProblems.stream().anyMatch(p -> p.severity().equals("ERROR"))) {
status.setText(
"Preview blocked: "
+ bindingProblems.stream()
.filter(p -> p.severity().equals("ERROR"))
.map(p -> p.message())
.collect(java.util.stream.Collectors.joining("; ")));
return;
}
var selected = examples.getValue();
if (selected == null || projection == null) return;
long ticket = renderSequence.incrementAndGet();
@@ -1,5 +1,6 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.protocol.BindingMetadata;
import java.util.function.*;
import javafx.scene.control.*;
import javafx.scene.layout.*;
@@ -18,6 +19,8 @@ final class DirectBindingEditor extends VBox {
property = field("property"),
path = field("path"),
converter = field("converter");
private final ComboBox<BindingMetadata.Converter> converters = new ComboBox<>();
private final Button inspect = new Button("Find Converters / Validate");
private final ComboBox<String> direction = new ComboBox<>();
private final CheckBox replace = new CheckBox("Replace conflicting bindings");
private final Label status = new Label("Open a valid FXML document to edit bindings.");
@@ -29,9 +32,23 @@ final class DirectBindingEditor extends VBox {
private boolean loading, writable, active, followingCanvas;
private final Consumer<String> navigate;
DirectBindingEditor(Consumer<Intent> edit, Consumer<String> navigate) {
DirectBindingEditor(
Consumer<Intent> edit,
Consumer<String> navigate,
Consumer<DirectBindingDocument.Values> discover) {
this.navigate = navigate;
setSpacing(5);
converters.setId("direct-converters");
converters.setPrefWidth(680);
inspect.setOnAction(e -> discover.accept(values()));
converters.setOnAction(
e -> {
var c = converters.getValue();
if (c != null) converter.setText(c.name());
});
for (var field : java.util.List.of(control, property, path))
field.textProperty().addListener((o, a, b) -> converters.getItems().clear());
direction.valueProperty().addListener((o, a, b) -> converters.getItems().clear());
bindings.setId("direct-bindings");
bindings.setPrefHeight(100);
direction.setId("direct-direction");
@@ -73,7 +90,13 @@ final class DirectBindingEditor extends VBox {
new Label("Direction"),
direction);
grid.addRow(1, new Label("Model path"), path, new Label("Converter (optional)"), converter);
getChildren().addAll(bindings, grid, new HBox(8, fresh, apply, remove, replace), status);
getChildren()
.addAll(
bindings,
grid,
new HBox(8, inspect, converters),
new HBox(8, fresh, apply, remove, replace),
status);
buttons();
}
@@ -120,6 +143,7 @@ final class DirectBindingEditor extends VBox {
void load(String text, boolean editable) {
if (source.equals(text) && writable == editable) return;
converters.getItems().clear();
source = text;
writable = editable;
loading = true;
@@ -131,8 +155,8 @@ final class DirectBindingEditor extends VBox {
bindings.getSelectionModel().clearSelection();
status.setText(
doc.errors().isEmpty()
? "Select a DirectBinding or choose New binding. Converter types are not yet"
+ " verified."
? "Select a DirectBinding or choose New binding. Use Find Converters / Validate to"
+ " check this draft."
: String.join("\n", doc.errors()));
} catch (Exception error) {
bindings.getItems().clear();
@@ -162,6 +186,35 @@ final class DirectBindingEditor extends VBox {
});
}
void metadata(
BindingMetadata metadata, DirectBindingDocument.Values requested, String provenance) {
status.setText(
provenance
+ "\n"
+ (metadata.problems().isEmpty()
? "No detected binding errors."
: metadata.problems().stream()
.map(
p ->
p.severity()
+ " "
+ p.control()
+ "."
+ p.property()
+ ": "
+ p.message())
.collect(java.util.stream.Collectors.joining("\n"))));
if (requested == null || !values().equals(requested)) return;
converters.getItems().setAll(metadata.converters());
var matches =
metadata.converters().stream().filter(c -> c.status().equals("COMPATIBLE")).toList();
if (converter.getText().isBlank() && matches.size() == 1) {
converters.setValue(matches.getFirst());
status.setText(
status.getText() + "\nUnique compatible Converter proposed; review and Apply binding.");
}
}
void error(String message) {
status.setText(message);
}
@@ -267,6 +267,88 @@ public final class ProjectionHostChecks {
return doc.snapshot().toCompletableFuture().get().text();
}
private static boolean bindingStatus(DesignerPane pane, String text) throws Exception {
return fx(() -> ((Label) pane.lookup("#direct-status")).getText().contains(text));
}
private static void converterEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
bindingTab(pane, "Direct bindings");
selectDirect(pane, "rankInput");
click(pane, "Find Converters / Validate");
await(() -> fx(() -> !((ComboBox<?>) pane.lookup("#direct-converters")).getItems().isEmpty()));
check(
fx(
() ->
((TextField) pane.lookup("#direct-converter"))
.getText()
.equals("fixture.components.RankConverter")),
"Existing Converter changed");
check(documentText(document).equals(full), "Validation altered source");
directField(pane, "converter", "");
click(pane, "Apply binding");
await(() -> !documentText(document).equals(full));
String invalid = documentText(document);
await(() -> fx(() -> source(pane).getText().equals(invalid)));
click(pane, "Render drafts");
await(() -> bindingStatus(pane, "Incompatible value types"));
await(
() ->
fx(
() ->
pane.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& l.getText() != null
&& l.getText().startsWith("Preview blocked:"))));
selectDirect(pane, "rankInput");
click(pane, "Find Converters / Validate");
await(
() ->
fx(
() ->
((TextField) pane.lookup("#direct-converter"))
.getText()
.equals("fixture.components.RankConverter")));
check(documentText(document).equals(invalid), "Proposal applied without review");
click(pane, "Apply binding");
await(() -> !documentText(document).equals(invalid));
String repaired = documentText(document);
click(pane, "Undo");
await(() -> documentText(document).equals(invalid));
click(pane, "Redo");
await(() -> documentText(document).equals(repaired));
await(() -> fx(() -> source(pane).getText().equals(repaired)));
selectDirect(pane, "rankInput");
directField(pane, "converter", "fixture.components.LegacyRankConverter");
click(pane, "Apply binding");
await(() -> documentText(document).contains("LegacyRankConverter"));
await(() -> fx(() -> source(pane).getText().contains("LegacyRankConverter")));
selectDirect(pane, "rankInput");
click(pane, "Find Converters / Validate");
await(() -> bindingStatus(pane, "WARNING rankInput.text"));
check(
fx(
() ->
((TextField) pane.lookup("#direct-converter"))
.getText()
.equals("fixture.components.LegacyRankConverter")),
"Unverified explicit Converter replaced");
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
click(pane, "New binding");
directField(pane, "control", "action");
directField(pane, "property", "text");
directField(pane, "path", "name");
click(pane, "Find Converters / Validate");
await(() -> fx(() -> ((ComboBox<?>) pane.lookup("#direct-converters")).getItems().size() > 1));
check(
fx(() -> ((TextField) pane.lookup("#direct-converter")).getText().isEmpty()),
"Ambiguous candidates silently selected");
check(documentText(document).equals(full), "Converter browsing altered source");
}
private static void directBindingEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
bindingTab(pane, "Direct bindings");
@@ -697,6 +779,7 @@ public final class ProjectionHostChecks {
&& ((TextField) pane.lookup("#direct-path")).getText().equals("rank")),
"Discovery did not populate binding form");
check(documentText(document).equals(full), "Discovery handoff changed source before Apply");
converterEdits(pane, document, full);
directBindingEdits(pane, document, full);
document
.save(document.snapshot().toCompletableFuture().get().revision())