Add shared source-preserving Computed binding inspector

This commit is contained in:
2026-11-13 13:42:19 +01:00
parent 99d93a11fc
commit 510ce35b60
12 changed files with 639 additions and 224 deletions
+1 -1
View File
@@ -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 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. 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. 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. 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.
@@ -15,8 +15,7 @@ final class BindingBrowser extends VBox {
final Label status = new Label("Build and Refresh, then discover binding targets."); final Label status = new Label("Build and Refresh, then discover binding targets.");
private boolean updating; private boolean updating;
BindingBrowser( BindingBrowser(BiConsumer<String, String> discover, Consumer<BindingDocument.Values> create) {
BiConsumer<String, String> discover, Consumer<DirectBindingDocument.Values> create) {
setSpacing(5); setSpacing(5);
paths.setId("binding-path"); paths.setId("binding-path");
directions.setId("binding-direction"); directions.setId("binding-direction");
@@ -40,7 +39,7 @@ final class BindingBrowser extends VBox {
var target = targets.getSelectionModel().getSelectedItem(); var target = targets.getSelectionModel().getSelectedItem();
if (target != null) if (target != null)
create.accept( create.accept(
new DirectBindingDocument.Values( new BindingDocument.Values(
target.control(), target.property(), path(), directions.getValue(), "")); target.control(), target.property(), path(), directions.getValue(), ""));
}); });
paths.setOnAction( paths.setOnAction(
@@ -4,15 +4,42 @@ import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import java.util.*; import java.util.*;
import org.w3c.dom.*; import org.w3c.dom.*;
/** Direct binding edit intents translated into lexical ranges in one immutable source revision. */ /** Binding edit intents translated into lexical ranges in one immutable source revision. */
final class DirectBindingDocument { final class BindingDocument {
static final String FX = "http://javafx.com/fxml/1", PACKAGE = "de.neitzel.fx.view."; static final String FX = "http://javafx.com/fxml/1", PACKAGE = "de.neitzel.fx.view.";
record Values(String control, String property, String path, String direction, String converter) {} enum Kind {
DIRECT,
COMPUTED;
String element() {
return this == DIRECT ? "DirectBinding" : "ComputedBinding";
}
}
record Values(
String control,
String property,
String path,
String direction,
String converter,
String method) {
Values(String control, String property, String path, String direction, String converter) {
this(control, property, path, direction, converter, "");
}
static Values computed(String control, String property, String method) {
return new Values(control, property, "", "view", "", method);
}
}
record Entry(int index, Values values, boolean editable) { record Entry(int index, Values values, boolean editable) {
public String toString() { public String toString() {
return values.control() + "." + values.property() + " ← " + values.path(); return values.control()
+ "."
+ values.property()
+ " ← "
+ (values.method().isEmpty() ? values.path() : values.method());
} }
} }
@@ -25,6 +52,7 @@ final class DirectBindingDocument {
} }
} }
final Kind kind;
final String text; final String text;
final Document document; final Document document;
final XmlSource source; final XmlSource source;
@@ -33,7 +61,12 @@ final class DirectBindingDocument {
private final List<String> imports = new ArrayList<>(); private final List<String> imports = new ArrayList<>();
private final Map<Element, Integer> indexes = new IdentityHashMap<>(); private final Map<Element, Integer> indexes = new IdentityHashMap<>();
DirectBindingDocument(String text) throws Exception { BindingDocument(String text) throws Exception {
this(text, Kind.DIRECT);
}
BindingDocument(String text, Kind kind) throws Exception {
this.kind = kind;
this.text = text; this.text = text;
document = FxmlProjection.parse(text); document = FxmlProjection.parse(text);
source = new XmlSource(text); source = new XmlSource(text);
@@ -49,7 +82,7 @@ final class DirectBindingDocument {
if (elements.size() != source.tags.size()) if (elements.size() != source.tags.size())
throw new IllegalArgumentException("XML source ranges do not match parsed elements"); throw new IllegalArgumentException("XML source ranges do not match parsed elements");
for (var e : elements) for (var e : elements)
if (binding(e, "DirectBinding")) if (binding(e, kind.element()))
entries.add(new Entry(indexes.get(e), values(e), !hasElementChildren(e))); entries.add(new Entry(indexes.get(e), values(e), !hasElementChildren(e)));
} }
@@ -81,7 +114,10 @@ final class DirectBindingDocument {
return named(e, name) && bindingList(e) != null; return named(e, name) && bindingList(e) != null;
} }
private static Values values(Element e) { private Values values(Element e) {
if (kind == Kind.COMPUTED)
return Values.computed(
e.getAttribute("control"), e.getAttribute("property"), e.getAttribute("method"));
return new Values( return new Values(
e.getAttribute("control"), e.getAttribute("control"),
e.getAttribute("property"), e.getAttribute("property"),
@@ -120,6 +156,10 @@ final class DirectBindingDocument {
if (ids.getOrDefault(e.getAttribute("control"), 0) != 1) if (ids.getOrDefault(e.getAttribute("control"), 0) != 1)
errors.add("Missing or ambiguous control: " + e.getAttribute("control")); errors.add("Missing or ambiguous control: " + e.getAttribute("control"));
if (e.getAttribute("property").isBlank()) errors.add("Missing property: " + target); if (e.getAttribute("property").isBlank()) errors.add("Missing property: " + target);
if (binding(e, "ComputedBinding")
&& !e.getAttribute("method")
.matches("#?[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*"))
errors.add("Invalid or missing Controller method: " + target);
if (binding(e, "DirectBinding")) { if (binding(e, "DirectBinding")) {
if (!e.getAttribute("path") if (!e.getAttribute("path")
.matches( .matches(
@@ -165,7 +205,15 @@ final class DirectBindingDocument {
if (original != null) { if (original != null) {
var changed = new LinkedHashMap<String, String>(); var changed = new LinkedHashMap<String, String>();
Map<String, String> fields = Map<String, String> fields =
Map.of( kind == Kind.COMPUTED
? Map.of(
"control",
value.control(),
"property",
value.property(),
"method",
value.method())
: Map.of(
"control", "control",
value.control(), value.control(),
"property", "property",
@@ -186,19 +234,25 @@ final class DirectBindingDocument {
String node = String node =
"<" "<"
+ PACKAGE + PACKAGE
+ "DirectBinding control=\"" + kind.element()
+ " control=\""
+ XmlSource.escape(value.control(), '"') + XmlSource.escape(value.control(), '"')
+ "\" property=\"" + "\" property=\""
+ XmlSource.escape(value.property(), '"') + XmlSource.escape(value.property(), '"')
+ "\" path=\"" + "\"";
if (kind == Kind.COMPUTED)
node += " method=\"" + XmlSource.escape(value.method(), '"') + "\"";
else
node +=
" path=\""
+ XmlSource.escape(value.path(), '"') + XmlSource.escape(value.path(), '"')
+ "\" direction=\"" + "\" direction=\""
+ XmlSource.escape(value.direction(), '"') + XmlSource.escape(value.direction(), '"')
+ "\"" + "\""
+ (value.converter().isEmpty() + (value.converter().isEmpty()
? "" ? ""
: " converter=\"" + XmlSource.escape(value.converter(), '"') + "\"") : " converter=\"" + XmlSource.escape(value.converter(), '"') + "\"");
+ "/>"; node += "/>";
var lists = elements.stream().filter(this::list).toList(); var lists = elements.stream().filter(this::list).toList();
if (!lists.isEmpty()) { if (!lists.isEmpty()) {
Element container = lists.getFirst(); Element container = lists.getFirst();
@@ -6,19 +6,23 @@ import javafx.scene.control.*;
import javafx.scene.layout.*; import javafx.scene.layout.*;
/** View-wide declarations and an explicit, revision-bound edit form shared by both hosts. */ /** View-wide declarations and an explicit, revision-bound edit form shared by both hosts. */
final class DirectBindingEditor extends VBox { final class BindingEditor extends VBox {
record Intent( record Intent(
BindingDocument.Kind kind,
String source, String source,
Integer index, Integer index,
DirectBindingDocument.Values values, BindingDocument.Values values,
boolean remove, boolean remove,
boolean replace) {} boolean replace) {}
private final ListView<DirectBindingDocument.Entry> bindings = new ListView<>(); private final ListView<BindingDocument.Entry> bindings = new ListView<>();
private final TextField control = field("control"), private final TextField control = field("control"),
property = field("property"), property = field("property"),
path = field("path"), path = field("path"),
converter = field("converter"); converter = field("converter"),
method = field("method");
private final ComboBox<BindingMetadata.Computed> methods = new ComboBox<>();
private final BindingDocument.Kind kind;
private final ComboBox<BindingMetadata.Converter> converters = new ComboBox<>(); private final ComboBox<BindingMetadata.Converter> converters = new ComboBox<>();
private final Button inspect = new Button("Find Converters / Validate"); private final Button inspect = new Button("Find Converters / Validate");
private final ComboBox<String> direction = new ComboBox<>(); private final ComboBox<String> direction = new ComboBox<>();
@@ -32,11 +36,30 @@ final class DirectBindingEditor extends VBox {
private boolean loading, writable, active, followingCanvas; private boolean loading, writable, active, followingCanvas;
private final Consumer<String> navigate; private final Consumer<String> navigate;
DirectBindingEditor( BindingEditor(
BindingDocument.Kind kind,
Consumer<Intent> edit, Consumer<Intent> edit,
Consumer<String> navigate, Consumer<String> navigate,
Consumer<DirectBindingDocument.Values> discover) { Consumer<BindingDocument.Values> discover) {
this.kind = kind;
boolean computed = kind == BindingDocument.Kind.COMPUTED;
String prefix = computed ? "computed-" : "direct-";
this.navigate = navigate; this.navigate = navigate;
for (var field : java.util.List.of(control, property, path, converter, method))
field.setId(prefix + field.getId());
methods.setId("computed-sources");
methods.setPrefWidth(680);
methods.setOnAction(
e -> {
var m = methods.getValue();
if (m != null) method.setText(m.method());
});
if (computed) {
inspect.setText("Find methods / Validate");
fresh.setText("New Computed binding");
apply.setText("Apply Computed binding");
remove.setText("Remove Computed binding");
}
setSpacing(5); setSpacing(5);
converters.setId("direct-converters"); converters.setId("direct-converters");
converters.setPrefWidth(680); converters.setPrefWidth(680);
@@ -49,18 +72,24 @@ final class DirectBindingEditor extends VBox {
for (var field : java.util.List.of(control, property, path)) for (var field : java.util.List.of(control, property, path))
field.textProperty().addListener((o, a, b) -> converters.getItems().clear()); field.textProperty().addListener((o, a, b) -> converters.getItems().clear());
direction.valueProperty().addListener((o, a, b) -> converters.getItems().clear()); direction.valueProperty().addListener((o, a, b) -> converters.getItems().clear());
bindings.setId("direct-bindings"); bindings.setId(prefix + "bindings");
bindings.setPrefHeight(100); bindings.setPrefHeight(100);
direction.setId("direct-direction"); direction.setId("direct-direction");
direction.getItems().setAll("view", "model", "both"); direction.getItems().setAll("view", "model", "both");
direction.setValue("both"); direction.setValue("both");
replace.setId("direct-replace"); replace.setId(prefix + "replace");
status.setId("direct-status"); status.setId(prefix + "status");
status.setWrapText(true); status.setWrapText(true);
fresh.setOnAction(e -> newBinding(new DirectBindingDocument.Values("", "", "", "both", ""))); fresh.setOnAction(
e ->
newBinding(
computed
? BindingDocument.Values.computed("", "", "")
: new BindingDocument.Values("", "", "", "both", "")));
apply.setOnAction( apply.setOnAction(
e -> edit.accept(new Intent(source, selected, values(), false, replace.isSelected()))); e ->
remove.setOnAction(e -> edit.accept(new Intent(source, selected, values(), true, false))); edit.accept(new Intent(kind, source, selected, values(), false, replace.isSelected())));
remove.setOnAction(e -> edit.accept(new Intent(kind, source, selected, values(), true, false)));
bindings bindings
.getSelectionModel() .getSelectionModel()
.selectedItemProperty() .selectedItemProperty()
@@ -81,6 +110,7 @@ final class DirectBindingEditor extends VBox {
var grid = new GridPane(); var grid = new GridPane();
grid.setHgap(8); grid.setHgap(8);
grid.setVgap(4); grid.setVgap(4);
if (!computed)
grid.addRow( grid.addRow(
0, 0,
new Label("Control (fx:id)"), new Label("Control (fx:id)"),
@@ -89,12 +119,19 @@ final class DirectBindingEditor extends VBox {
property, property,
new Label("Direction"), new Label("Direction"),
direction); direction);
if (!computed)
grid.addRow(1, new Label("Model path"), path, new Label("Converter (optional)"), converter); grid.addRow(1, new Label("Model path"), path, new Label("Converter (optional)"), converter);
else {
grid.addRow(0, new Label("Control (fx:id)"), control, new Label("Property"), property);
grid.addRow(1, new Label("Controller method"), method);
for (var field : java.util.List.of(control, property))
field.textProperty().addListener((o, a, b) -> methods.getItems().clear());
}
getChildren() getChildren()
.addAll( .addAll(
bindings, bindings,
grid, grid,
new HBox(8, inspect, converters), new HBox(8, inspect, computed ? methods : converters),
new HBox(8, fresh, apply, remove, replace), new HBox(8, fresh, apply, remove, replace),
status); status);
buttons(); buttons();
@@ -102,13 +139,16 @@ final class DirectBindingEditor extends VBox {
private static TextField field(String name) { private static TextField field(String name) {
var f = new TextField(); var f = new TextField();
f.setId("direct-" + name); f.setId(name);
f.setPrefColumnCount(14); f.setPrefColumnCount(14);
return f; return f;
} }
private DirectBindingDocument.Values values() { private BindingDocument.Values values() {
return new DirectBindingDocument.Values( if (kind == BindingDocument.Kind.COMPUTED)
return BindingDocument.Values.computed(
control.getText().trim(), property.getText().trim(), method.getText().trim());
return new BindingDocument.Values(
control.getText().trim(), control.getText().trim(),
property.getText().trim(), property.getText().trim(),
path.getText().trim(), path.getText().trim(),
@@ -116,15 +156,16 @@ final class DirectBindingEditor extends VBox {
converter.getText().trim()); converter.getText().trim());
} }
private void fill(DirectBindingDocument.Values v) { private void fill(BindingDocument.Values v) {
control.setText(v.control()); control.setText(v.control());
property.setText(v.property()); property.setText(v.property());
path.setText(v.path()); path.setText(v.path());
direction.setValue(v.direction()); direction.setValue(v.direction());
converter.setText(v.converter()); converter.setText(v.converter());
method.setText(v.method());
} }
void newBinding(DirectBindingDocument.Values v) { void newBinding(BindingDocument.Values v) {
loading = true; loading = true;
bindings.getSelectionModel().clearSelection(); bindings.getSelectionModel().clearSelection();
loading = false; loading = false;
@@ -144,19 +185,21 @@ final class DirectBindingEditor extends VBox {
void load(String text, boolean editable) { void load(String text, boolean editable) {
if (source.equals(text) && writable == editable) return; if (source.equals(text) && writable == editable) return;
converters.getItems().clear(); converters.getItems().clear();
methods.getItems().clear();
source = text; source = text;
writable = editable; writable = editable;
loading = true; loading = true;
selected = null; selected = null;
active = false; active = false;
try { try {
var doc = new DirectBindingDocument(text); var doc = new BindingDocument(text, kind);
bindings.getItems().setAll(doc.entries); bindings.getItems().setAll(doc.entries);
bindings.getSelectionModel().clearSelection(); bindings.getSelectionModel().clearSelection();
status.setText( status.setText(
doc.errors().isEmpty() doc.errors().isEmpty()
? "Select a DirectBinding or choose New binding. Use Find Converters / Validate to" ? "Select a "
+ " check this draft." + kind.element()
+ " or create a new binding. Validate to check this draft."
: String.join("\n", doc.errors())); : String.join("\n", doc.errors()));
} catch (Exception error) { } catch (Exception error) {
bindings.getItems().clear(); bindings.getItems().clear();
@@ -186,8 +229,7 @@ final class DirectBindingEditor extends VBox {
}); });
} }
void metadata( void metadata(BindingMetadata metadata, BindingDocument.Values requested, String provenance) {
BindingMetadata metadata, DirectBindingDocument.Values requested, String provenance) {
status.setText( status.setText(
provenance provenance
+ " · ViewModel: " + " · ViewModel: "
@@ -209,6 +251,10 @@ final class DirectBindingEditor extends VBox {
+ p.message()) + p.message())
.collect(java.util.stream.Collectors.joining("\n")))); .collect(java.util.stream.Collectors.joining("\n"))));
if (requested == null || !values().equals(requested)) return; if (requested == null || !values().equals(requested)) return;
if (kind == BindingDocument.Kind.COMPUTED) {
methods.getItems().setAll(metadata.computed());
return;
}
converters.getItems().setAll(metadata.converters()); converters.getItems().setAll(metadata.converters());
var matches = var matches =
metadata.converters().stream().filter(c -> c.status().equals("COMPATIBLE")).toList(); metadata.converters().stream().filter(c -> c.status().equals("COMPATIBLE")).toList();
@@ -43,6 +43,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
if (hit == null) return; if (hit == null) return;
String id = hit.getFxId(); String id = hit.getFxId();
if (bindingEditor != null) bindingEditor.selectControl(id); if (bindingEditor != null) bindingEditor.selectControl(id);
if (computedEditor != null) computedEditor.selectControl(id);
if (id == null || !projection.components().containsKey(id)) return; if (id == null || !projection.components().containsKey(id)) return;
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id)); componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
componentInspector componentInspector
@@ -114,7 +115,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private DesignerWorkflow interactiveWorkflow; private DesignerWorkflow interactiveWorkflow;
private DesignerWorkflow bindingWorkflow; private DesignerWorkflow bindingWorkflow;
private BindingBrowser bindingBrowser; private BindingBrowser bindingBrowser;
private DirectBindingEditor bindingEditor; private BindingEditor bindingEditor, computedEditor;
private String validatedSource, validatingSource; private String validatedSource, validatingSource;
private boolean validationPreview; private boolean validationPreview;
private java.util.List<de.neitzel.fxdesigner.protocol.BindingMetadata.Problem> bindingProblems = private java.util.List<de.neitzel.fxdesigner.protocol.BindingMetadata.Problem> bindingProblems =
@@ -180,7 +181,18 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
launch.tools(), launch.tools(),
this::log); this::log);
bindingEditor = bindingEditor =
new DirectBindingEditor( new BindingEditor(
BindingDocument.Kind.DIRECT,
this::editBinding,
id -> {
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));
computedEditor =
new BindingEditor(
BindingDocument.Kind.COMPUTED,
this::editBinding, this::editBinding,
id -> { id -> {
if (kit.editor().getFxomDocument() == null) return; if (kit.editor().getFxomDocument() == null) return;
@@ -334,7 +346,10 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
discoveryTab.setClosable(false); discoveryTab.setClosable(false);
directBindingTab = new Tab("Direct bindings", bindingEditor); directBindingTab = new Tab("Direct bindings", bindingEditor);
directBindingTab.setClosable(false); directBindingTab.setClosable(false);
var bindings = new TitledPane("Bindings", new TabPane(directBindingTab, discoveryTab)); var computedTab = new Tab("Computed bindings", computedEditor);
computedTab.setClosable(false);
var bindings =
new TitledPane("Bindings", new TabPane(directBindingTab, computedTab, discoveryTab));
bindings.setExpanded(false); bindings.setExpanded(false);
center.setBottom(new VBox(properties, bindings)); center.setBottom(new VBox(properties, bindings));
output.setEditable(false); output.setEditable(false);
@@ -475,6 +490,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
} }
draft = value; draft = value;
bindingEditor.load(value.text(), value.writable()); bindingEditor.load(value.text(), value.writable());
computedEditor.load(value.text(), value.writable());
submittedSource = null; submittedSource = null;
commitSource(); commitSource();
return; return;
@@ -497,6 +513,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
String selectedId = hit == null ? null : hit.getFxId(); String selectedId = hit == null ? null : hit.getFxId();
draft = value; draft = value;
bindingEditor.load(value.text(), value.writable()); bindingEditor.load(value.text(), value.writable());
computedEditor.load(value.text(), value.writable());
if (!source.getText().equals(value.text())) { if (!source.getText().equals(value.text())) {
int anchor = source.getAnchor(), caret = source.getCaretPosition(); int anchor = source.getAnchor(), caret = source.getCaretPosition();
var edits = var edits =
@@ -651,14 +668,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
bindingBrowser.invalidate(message); bindingBrowser.invalidate(message);
} }
private void editBinding(DirectBindingEditor.Intent intent) { private void editBinding(BindingEditor.Intent intent) {
withDraft( withDraft(
() -> { () -> {
try { try {
if (!draft.writable()) throw new IllegalStateException("Document is read-only"); if (!draft.writable()) throw new IllegalStateException("Document is read-only");
if (!draft.text().equals(intent.source())) if (!draft.text().equals(intent.source()))
throw new IllegalStateException("Document changed; select the binding again"); throw new IllegalStateException("Document changed; select the binding again");
var bindings = new DirectBindingDocument(draft.text()); var bindings = new BindingDocument(draft.text(), intent.kind());
var edits = var edits =
intent.remove() intent.remove()
? bindings.remove(java.util.Objects.requireNonNull(intent.index())) ? bindings.remove(java.util.Objects.requireNonNull(intent.index()))
@@ -666,17 +683,18 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
submit( submit(
edits, edits,
intent.remove() intent.remove()
? "Remove DirectBinding" ? "Remove " + intent.kind().element()
: intent.index() == null ? "Create DirectBinding" : "Edit DirectBinding"); : (intent.index() == null ? "Create " : "Edit ") + intent.kind().element());
} catch (Exception error) { } catch (Exception error) {
bindingEditor.error(error.getMessage()); (intent.kind() == BindingDocument.Kind.COMPUTED ? computedEditor : bindingEditor)
.error(error.getMessage());
} }
}); });
} }
private boolean bindingPreviewAllowed() { private boolean bindingPreviewAllowed() {
try { try {
var errors = new DirectBindingDocument(draft.text()).errors(); var errors = new BindingDocument(draft.text()).errors();
if (errors.isEmpty()) return true; if (errors.isEmpty()) return true;
status.setText("Preview blocked: " + String.join("; ", errors)); status.setText("Preview blocked: " + String.join("; ", errors));
if (projection != null) if (projection != null)
@@ -690,7 +708,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
return false; return false;
} }
private void validateBindings(DirectBindingDocument.Values values, boolean preview) { private void validateBindings(BindingDocument.Values values, boolean preview) {
withDraft( withDraft(
() -> { () -> {
if (preview && draft.text().equals(validatingSource)) { if (preview && draft.text().equals(validatingSource)) {
@@ -735,6 +753,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
: "Current successful build") : "Current successful build")
+ " · draft revision " + " · draft revision "
+ captured.revision()); + captured.revision());
computedEditor.metadata(
metadata,
values,
(last
? "Explicit retained build (outdated)"
: "Current successful build")
+ " · draft revision "
+ captured.revision());
status.setText( status.setText(
"Binding validation complete: " "Binding validation complete: "
+ metadata.problems().size() + metadata.problems().size()
@@ -0,0 +1,240 @@
package de.neitzel.fxdesigner.kit;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fxdesigner.contracts.*;
import java.net.URI;
import java.util.*;
import org.junit.jupiter.api.Test;
class BindingDocumentTest {
private static final String P = "de.neitzel.fx.view.";
private static final BindingDocument.Values VALUE =
new BindingDocument.Values("label", "text", "name", "view", "");
private String apply(String text, List<EditBatch.Replacement> edits) throws Exception {
String result =
new EditBatch(7, "binding", edits)
.applyTo(new DocumentSnapshot(URI.create("file:///test.fxml"), 7, text, true, false));
new BindingDocument(result);
return result;
}
@Test
void createsRequiredContainersWithoutSerializingTheDocument() throws Exception {
for (String root :
List.of(
"<VBox/>",
"<VBox xmlns:fx='urn:other'/>",
"<VBox><!-- untouched -->\r\n</VBox>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define/></VBox>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define><"
+ P
+ "BindingList/></f:define></VBox>")) {
String text = "<?neitzel-model app.Person?>\n" + root;
var doc = new BindingDocument(text);
String result = apply(text, doc.write(null, VALUE, false));
var created = new BindingDocument(result);
assertEquals(1, created.entries.size());
assertEquals(VALUE, created.entries.getFirst().values());
assertTrue(result.startsWith("<?neitzel-model app.Person?>\n"));
assertFalse(result.contains("<?import"));
if (text.contains("untouched")) assertTrue(result.contains("<!-- untouched -->\r\n"));
if (text.contains("urn:other"))
assertTrue(result.contains("xmlns:fx1=\"http://javafx.com/fxml/1\""));
}
}
private String existing() {
return "<?neitzel-model app.Person?>\n"
+ "<?import de.neitzel.fx.view.*?>\n"
+ "<VBox xmlns:fx='http://javafx.com/fxml/1'><!-- before"
+ " --><fx:define><BindingList><!-- preserve --><DirectBinding control = 'label'"
+ " property='text' path = 'name' direction='view' converter='app.Old'"
+ " custom='untouched'/><!-- after --><app.Unknown"
+ " value='keep'/></BindingList></fx:define><Label fx:id='label'/></VBox>";
}
@Test
void changesOnlyRequestedAttributeRangesAndRetainsUnknownContent() throws Exception {
String text = existing();
var doc = new BindingDocument(text);
int index = doc.entries.getFirst().index();
var value = new BindingDocument.Values("label", "text", "home.street", "both", "app.New");
String updated = apply(text, doc.write(index, value, false));
assertEquals(
text.replace("path = 'name'", "path = 'home.street'")
.replace("direction='view'", "direction='both'")
.replace("converter='app.Old'", "converter='app.New'"),
updated);
assertTrue(new BindingDocument(updated).write(index, value, false).isEmpty());
String escaped =
apply(
text,
doc.write(
index, new BindingDocument.Values("label", "text", "a'&<", "view", ""), false));
assertEquals("a'&<", new BindingDocument(escaped).entries.getFirst().values().path());
assertFalse(escaped.contains("converter='app.Old'"));
assertFalse(new BindingDocument(escaped).errors().isEmpty());
}
@Test
void removesOnlyTheSelectedElementIncludingPairedTags() throws Exception {
String text =
existing()
.replace("custom='untouched'/>", "custom='untouched'><!-- inner --></DirectBinding>");
var doc = new BindingDocument(text);
var tag = doc.source.tags.get(doc.entries.getFirst().index());
String result = apply(text, doc.remove(doc.entries.getFirst().index()));
assertEquals(text.substring(0, tag.start()) + text.substring(tag.end()), result);
assertTrue(result.contains("<!-- preserve --><!-- after -->"));
assertTrue(result.contains("app.Unknown"));
}
@Test
void replacesAllConflictingDirectAndComputedBindingsAtomically() throws Exception {
String text =
existing()
.replace(
"<!-- after -->",
"<ComputedBinding control='label' property='text' method='#caption'/><!-- after"
+ " -->");
var doc = new BindingDocument(text);
assertTrue(doc.errors().stream().anyMatch(s -> s.contains("Duplicate")));
assertThrows(BindingDocument.Conflict.class, () -> doc.write(null, VALUE, false));
String replaced = apply(text, doc.write(null, VALUE, true));
var parsed = new BindingDocument(replaced);
assertEquals(1, parsed.entries.size());
assertFalse(replaced.contains("<ComputedBinding"));
assertTrue(parsed.errors().isEmpty());
assertTrue(replaced.contains("app.Unknown"));
}
@Test
void supportsExplicitElementsWrapperAndRefusesNestedPropertyEdits() throws Exception {
String text =
"<?import de.neitzel.fx.view.*?><VBox"
+ " xmlns:fx='http://javafx.com/fxml/1'><fx:define><BindingList><elements><DirectBinding"
+ " control='label'><property>text</property></DirectBinding></elements></BindingList></fx:define></VBox>";
var doc = new BindingDocument(text);
assertFalse(doc.entries.getFirst().editable());
assertThrows(
IllegalArgumentException.class,
() -> doc.write(doc.entries.getFirst().index(), VALUE, false));
String result =
apply(
text,
doc.write(
null, new BindingDocument.Values("other", "text", "name", "view", ""), false));
assertEquals(2, new BindingDocument(result).entries.size());
}
@Test
void usesOnlyTheLibraryDeclarationsAndRejectsMalformedXml() throws Exception {
String text =
existing().replace("<?import de.neitzel.fx.view.*?>", "<?import app.DirectBinding?>");
assertTrue(new BindingDocument(text).entries.isEmpty());
assertThrows(Exception.class, () -> new BindingDocument("<VBox>"));
assertThrows(
Exception.class, () -> new BindingDocument("<!DOCTYPE x SYSTEM 'file:///etc/passwd'><x/>"));
}
private BindingDocument computed(String text) throws Exception {
return new BindingDocument(text, BindingDocument.Kind.COMPUTED);
}
@Test
void computedCreatesContainersAndRoundTripsEscapedMethods() throws Exception {
for (String root :
List.of(
"<VBox/>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define/></VBox>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define><"
+ P
+ "BindingList><elements/></"
+ P
+ "BindingList></f:define></VBox>")) {
String text = "<?neitzel-model app.Person?>\r\n" + root;
var value = BindingDocument.Values.computed("label", "text", "#a'&<");
String result = apply(text, computed(text).write(null, value, false));
assertEquals(value, computed(result).entries.getFirst().values());
assertTrue(result.startsWith("<?neitzel-model app.Person?>\r\n"));
assertFalse(result.contains(" direction="));
assertFalse(result.contains(" path="));
assertTrue(computed(result).errors().stream().anyMatch(e -> e.contains("Controller method")));
}
}
@Test
void computedChangesOnlyItsFieldsAndRemovesPairedDeclaration() throws Exception {
String text =
existing()
.replace(
"<app.Unknown",
"<ComputedBinding control = 'other' property='text' method = '#old'"
+ " custom='keep'><!-- inner --></ComputedBinding><Label"
+ " fx:id='other'/><app.Unknown");
var doc = computed(text);
int index = doc.entries.getFirst().index();
var value = BindingDocument.Values.computed("label", "accessibleText", "greeting");
String changed = apply(text, doc.write(index, value, false));
assertEquals(
text.replace("control = 'other'", "control = 'label'")
.replace("property='text' method", "property='accessibleText' method")
.replace("method = '#old'", "method = 'greeting'"),
changed);
assertTrue(computed(changed).write(index, value, false).isEmpty());
String removed = apply(changed, computed(changed).remove(index));
assertTrue(computed(removed).entries.isEmpty());
assertEquals(1, new BindingDocument(removed).entries.size());
assertTrue(removed.contains("<!-- preserve -->"));
}
@Test
void computedReplacementRemovesDirectAndComputedConflictsTogether() throws Exception {
String text =
existing()
.replace(
"<!-- after -->",
"<ComputedBinding control='label' property='text' method='#caption'/><!-- after"
+ " -->");
var doc = computed(text);
var value = BindingDocument.Values.computed("label", "text", "#greeting");
assertThrows(BindingDocument.Conflict.class, () -> doc.write(null, value, false));
String result = apply(text, doc.write(null, value, true));
assertTrue(new BindingDocument(result).entries.isEmpty());
assertEquals(1, computed(result).entries.size());
assertTrue(computed(result).errors().isEmpty());
assertTrue(result.contains("<!-- preserve -->"));
// Reverse replacement uses the same conflict policy.
assertThrows(
BindingDocument.Conflict.class,
() -> new BindingDocument(result).write(null, VALUE, false));
String direct = apply(result, new BindingDocument(result).write(null, VALUE, true));
assertTrue(computed(direct).entries.isEmpty());
assertEquals(1, new BindingDocument(direct).entries.size());
}
@Test
void computedNestedSyntaxIsVisibleRemovableButNotFlattened() throws Exception {
String text =
existing()
.replace(
"<app.Unknown",
"<ComputedBinding control='other'"
+ " property='text'><method>#caption</method></ComputedBinding><app.Unknown");
var doc = computed(text);
var entry = doc.entries.getFirst();
assertFalse(entry.editable());
assertThrows(
IllegalArgumentException.class,
() ->
doc.write(
entry.index(), BindingDocument.Values.computed("other", "text", "#new"), false));
assertTrue(computed(apply(text, doc.remove(entry.index()))).entries.isEmpty());
assertTrue(
computed(text.replace("<?import de.neitzel.fx.view.*?>", "<?import app.ComputedBinding?>"))
.entries
.isEmpty());
}
}
@@ -1,146 +0,0 @@
package de.neitzel.fxdesigner.kit;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fxdesigner.contracts.*;
import java.net.URI;
import java.util.*;
import org.junit.jupiter.api.Test;
class DirectBindingDocumentTest {
private static final String P = "de.neitzel.fx.view.";
private static final DirectBindingDocument.Values VALUE =
new DirectBindingDocument.Values("label", "text", "name", "view", "");
private String apply(String text, List<EditBatch.Replacement> edits) throws Exception {
String result =
new EditBatch(7, "binding", edits)
.applyTo(new DocumentSnapshot(URI.create("file:///test.fxml"), 7, text, true, false));
new DirectBindingDocument(result);
return result;
}
@Test
void createsRequiredContainersWithoutSerializingTheDocument() throws Exception {
for (String root :
List.of(
"<VBox/>",
"<VBox xmlns:fx='urn:other'/>",
"<VBox><!-- untouched -->\r\n</VBox>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define/></VBox>",
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define><"
+ P
+ "BindingList/></f:define></VBox>")) {
String text = "<?neitzel-model app.Person?>\n" + root;
var doc = new DirectBindingDocument(text);
String result = apply(text, doc.write(null, VALUE, false));
var created = new DirectBindingDocument(result);
assertEquals(1, created.entries.size());
assertEquals(VALUE, created.entries.getFirst().values());
assertTrue(result.startsWith("<?neitzel-model app.Person?>\n"));
assertFalse(result.contains("<?import"));
if (text.contains("untouched")) assertTrue(result.contains("<!-- untouched -->\r\n"));
if (text.contains("urn:other"))
assertTrue(result.contains("xmlns:fx1=\"http://javafx.com/fxml/1\""));
}
}
private String existing() {
return "<?neitzel-model app.Person?>\n"
+ "<?import de.neitzel.fx.view.*?>\n"
+ "<VBox xmlns:fx='http://javafx.com/fxml/1'><!-- before"
+ " --><fx:define><BindingList><!-- preserve --><DirectBinding control = 'label'"
+ " property='text' path = 'name' direction='view' converter='app.Old'"
+ " custom='untouched'/><!-- after --><app.Unknown"
+ " value='keep'/></BindingList></fx:define><Label fx:id='label'/></VBox>";
}
@Test
void changesOnlyRequestedAttributeRangesAndRetainsUnknownContent() throws Exception {
String text = existing();
var doc = new DirectBindingDocument(text);
int index = doc.entries.getFirst().index();
var value = new DirectBindingDocument.Values("label", "text", "home.street", "both", "app.New");
String updated = apply(text, doc.write(index, value, false));
assertEquals(
text.replace("path = 'name'", "path = 'home.street'")
.replace("direction='view'", "direction='both'")
.replace("converter='app.Old'", "converter='app.New'"),
updated);
assertTrue(new DirectBindingDocument(updated).write(index, value, false).isEmpty());
String escaped =
apply(
text,
doc.write(
index,
new DirectBindingDocument.Values("label", "text", "a'&<", "view", ""),
false));
assertEquals("a'&<", new DirectBindingDocument(escaped).entries.getFirst().values().path());
assertFalse(escaped.contains("converter='app.Old'"));
assertFalse(new DirectBindingDocument(escaped).errors().isEmpty());
}
@Test
void removesOnlyTheSelectedElementIncludingPairedTags() throws Exception {
String text =
existing()
.replace("custom='untouched'/>", "custom='untouched'><!-- inner --></DirectBinding>");
var doc = new DirectBindingDocument(text);
var tag = doc.source.tags.get(doc.entries.getFirst().index());
String result = apply(text, doc.remove(doc.entries.getFirst().index()));
assertEquals(text.substring(0, tag.start()) + text.substring(tag.end()), result);
assertTrue(result.contains("<!-- preserve --><!-- after -->"));
assertTrue(result.contains("app.Unknown"));
}
@Test
void replacesAllConflictingDirectAndComputedBindingsAtomically() throws Exception {
String text =
existing()
.replace(
"<!-- after -->",
"<ComputedBinding control='label' property='text' method='#caption'/><!-- after"
+ " -->");
var doc = new DirectBindingDocument(text);
assertTrue(doc.errors().stream().anyMatch(s -> s.contains("Duplicate")));
assertThrows(DirectBindingDocument.Conflict.class, () -> doc.write(null, VALUE, false));
String replaced = apply(text, doc.write(null, VALUE, true));
var parsed = new DirectBindingDocument(replaced);
assertEquals(1, parsed.entries.size());
assertFalse(replaced.contains("<ComputedBinding"));
assertTrue(parsed.errors().isEmpty());
assertTrue(replaced.contains("app.Unknown"));
}
@Test
void supportsExplicitElementsWrapperAndRefusesNestedPropertyEdits() throws Exception {
String text =
"<?import de.neitzel.fx.view.*?><VBox"
+ " xmlns:fx='http://javafx.com/fxml/1'><fx:define><BindingList><elements><DirectBinding"
+ " control='label'><property>text</property></DirectBinding></elements></BindingList></fx:define></VBox>";
var doc = new DirectBindingDocument(text);
assertFalse(doc.entries.getFirst().editable());
assertThrows(
IllegalArgumentException.class,
() -> doc.write(doc.entries.getFirst().index(), VALUE, false));
String result =
apply(
text,
doc.write(
null,
new DirectBindingDocument.Values("other", "text", "name", "view", ""),
false));
assertEquals(2, new DirectBindingDocument(result).entries.size());
}
@Test
void usesOnlyTheLibraryDeclarationsAndRejectsMalformedXml() throws Exception {
String text =
existing().replace("<?import de.neitzel.fx.view.*?>", "<?import app.DirectBinding?>");
assertTrue(new DirectBindingDocument(text).entries.isEmpty());
assertThrows(Exception.class, () -> new DirectBindingDocument("<VBox>"));
assertThrows(
Exception.class,
() -> new DirectBindingDocument("<!DOCTYPE x SYSTEM 'file:///etc/passwd'><x/>"));
}
}
@@ -482,6 +482,168 @@ public final class ProjectionHostChecks {
await(() -> fx(() -> source(pane).getText().equals(full))); await(() -> fx(() -> source(pane).getText().equals(full)));
} }
private static void computedField(DesignerPane pane, String field, String value)
throws Exception {
fx(
() -> {
((TextField) pane.lookup("#computed-" + field)).setText(value);
return null;
});
}
private static void selectComputed(DesignerPane pane, String control) throws Exception {
fx(
() -> {
var list = (ListView<Object>) pane.lookup("#computed-bindings");
list.getSelectionModel()
.select(
list.getItems().stream()
.filter(e -> e.toString().startsWith(control + "."))
.findFirst()
.orElseThrow());
return null;
});
}
private static void computedBindingEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
bindingTab(pane, "Computed bindings");
click(pane, "New Computed binding");
computedField(pane, "control", "action");
computedField(pane, "property", "text");
click(pane, "Find methods / Validate");
await(() -> fx(() -> !((ComboBox<?>) pane.lookup("#computed-sources")).getItems().isEmpty()));
fx(
() -> {
var methods =
(ComboBox<de.neitzel.fxdesigner.protocol.BindingMetadata.Computed>)
pane.lookup("#computed-sources");
var greeting =
methods.getItems().stream()
.filter(m -> m.method().equals("#greeting"))
.findFirst()
.orElseThrow();
check(greeting.status().equals("COMPATIBLE"), "Computed method type comparison failed");
methods.setValue(greeting);
return null;
});
check(documentText(document).equals(full), "Method choice changed source before Apply");
click(pane, "Apply Computed binding");
await(() -> !documentText(document).equals(full));
String created = documentText(document);
check(
created.contains(
"ComputedBinding control=\"action\" property=\"text\" method=\"#greeting\""),
"Computed creation failed");
click(pane, "Undo");
await(() -> documentText(document).equals(full));
click(pane, "Redo");
await(() -> documentText(document).equals(created));
await(() -> fx(() -> source(pane).getText().equals(created)));
selectComputed(pane, "action");
computedField(pane, "property", "accessibleText");
computedField(pane, "method", "greeting");
click(pane, "Apply Computed binding");
String changed =
created.replace(
"property=\"text\" method=\"#greeting\"",
"property=\"accessibleText\" method=\"greeting\"");
await(() -> documentText(document).equals(changed));
click(pane, "Undo");
await(() -> documentText(document).equals(created));
click(pane, "Redo");
await(() -> documentText(document).equals(changed));
await(() -> fx(() -> source(pane).getText().equals(changed)));
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
.get();
check(
java.nio.file.Files.readString(
java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document()))
.equals(changed),
"Computed save changed source");
click(pane, "Render drafts");
await(
() ->
fx(
() ->
object(pane, "home").getSceneGraphObject() instanceof SnapshotRegion r
&& r.image() != null
&& r.statusText().equals("Target runtime")));
selectComputed(pane, "action");
click(pane, "Remove Computed binding");
await(() -> !documentText(document).equals(changed));
String removed = documentText(document);
check(!removed.contains("ComputedBinding"), "Computed removal failed");
click(pane, "Undo");
await(() -> documentText(document).equals(changed));
click(pane, "Redo");
await(() -> documentText(document).equals(removed));
await(() -> fx(() -> source(pane).getText().equals(removed)));
click(pane, "New Computed binding");
computedField(pane, "control", "rankInput");
computedField(pane, "property", "text");
computedField(pane, "method", "#greeting");
click(pane, "Apply Computed binding");
await(
() ->
fx(
() ->
((Label) pane.lookup("#computed-status"))
.getText()
.contains("Replace conflicting bindings")));
check(documentText(document).equals(removed), "Computed silently replaced Direct binding");
fx(
() -> {
((CheckBox) pane.lookup("#computed-replace")).setSelected(true);
return null;
});
click(pane, "Apply Computed binding");
await(() -> !documentText(document).equals(removed));
String replaced = documentText(document);
check(
directDeclarations(replaced).stream()
.noneMatch(e -> e.getAttribute("control").equals("rankInput")),
"Computed replacement retained Direct conflict");
click(pane, "Undo");
await(() -> documentText(document).equals(removed));
click(pane, "Redo");
await(() -> documentText(document).equals(replaced));
await(() -> fx(() -> source(pane).getText().equals(replaced)));
selectComputed(pane, "rankInput");
computedField(pane, "method", "#invalidComputed");
click(pane, "Apply Computed binding");
await(() -> documentText(document).contains("method=\"#invalidComputed\""));
String invalid = documentText(document);
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
.get();
click(pane, "Render drafts");
await(
() ->
fx(
() ->
pane.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& l.getText() != null
&& l.getText().startsWith("Preview blocked:"))));
check(
java.nio.file.Files.readString(
java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document()))
.equals(invalid),
"Invalid Computed draft not saved");
click(pane, "Undo");
await(() -> documentText(document).equals(replaced));
click(pane, "Redo");
await(() -> documentText(document).equals(invalid));
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
}
private static void viewModelBindings( private static void viewModelBindings(
DesignerPane pane, DocumentSession document, String original) throws Exception { DesignerPane pane, DocumentSession document, String original) throws Exception {
bindingTab(pane, "Discover targets"); bindingTab(pane, "Discover targets");
@@ -859,6 +1021,7 @@ public final class ProjectionHostChecks {
check(documentText(document).equals(full), "Discovery handoff changed source before Apply"); check(documentText(document).equals(full), "Discovery handoff changed source before Apply");
converterEdits(pane, document, full); converterEdits(pane, document, full);
directBindingEdits(pane, document, full); directBindingEdits(pane, document, full);
computedBindingEdits(pane, document, full);
document document
.save(document.snapshot().toCompletableFuture().get().revision()) .save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture() .toCompletableFuture()
+29
View File
@@ -0,0 +1,29 @@
# Shared Computed-binding inspector
Both hosts now provide **Bindings → Computed bindings**. Select a declaration or choose **New Computed binding**, enter the target control's fx:id, property and Controller method, then **Apply Computed binding**. **Remove Computed binding** deletes the selected declaration. Selecting a declaration selects its canvas target; selecting a bound canvas control selects its declaration.
**Find methods / Validate** asks the target worker for public Controller methods and compares their return types with the entered target. Choose a method explicitly from the list or type its name; choosing a suggestion only prepares the form. The source changes on Apply. The list includes compatibility and unresolved/unavailable explanations. Existing unprefixed names remain accepted; discovered names use `#method`. Computed bindings have no Model path, direction or Converter fields.
## Source and history
Direct and Computed inspectors share one lexical document editor and form lifecycle. Creation adds a fully qualified declaration and required containers without serializing the document or rewriting imports. Changes touch only control/property/method attributes, preserving their existing quoting, unrelated attributes, comments and whitespace. Removal deletes only the selected element. Alternate FXML prefixes, explicit BindingList elements wrappers and self-closing containers use the same tested insertion rules.
Each Apply, Remove or explicit conflict replacement is one revision-checked host document command. Normal standalone and IntelliJ Undo/Redo restore the exact previous/new text. Source changes invalidate the active form selection, and read-only documents cannot be changed. Declarations with nested property elements remain visible and explicitly removable, but editing them requires Source; the inspector does not flatten unfamiliar syntax.
A target already used by a Direct or Computed declaration requires **Replace conflicting bindings**. Replacement removes the conflicting declarations and applies the requested binding atomically. This also provides explicit replacement between binding types; neither inspector silently takes over an occupied target.
## Validation and limits
Structural diagnostics include missing/ambiguous targets, missing properties and malformed method names. [Target-side validation](viewmodel-computed-validation.md) checks Controller method access, arity, return types and target compatibility without executing application methods. Errors block new previews while invalid drafts remain saveable and undoable. Build/draft freshness and explicit retained-build provenance continue to apply.
The inspector does not generate Controller methods or evaluate their bodies. Generated metadata, full collection authoring, structural reference refactoring and complete IDE Problems integration remain later work. Existing skipped human checks remain skipped.
## Automated qualification
Lexical tests cover container creation, escaping, exact attribute edits, paired-tag removal, cross-type conflict replacement, nested syntax and import identity. The same live UI scenario in both hosts discovers and chooses a compatible method without changing source, creates/changes/removes Computed declarations, replaces a Direct binding explicitly, saves and renders valid edits, saves a type-invalid edit while previews are blocked, and checks exact single-command Undo/Redo. Classpath and module-path applications use the real NeitzelLib target runtime. No manual testing is required.
Run `scripts/check-bootstrap.py` and `scripts/check-ide-host.py` using the pinned SDKs and commands in the [README](../README.md).
Qualification on 2026-11-13 passed on the pinned macOS baseline: **95 unit/adapter tests** (88 Maven, 7 IntelliJ), **36 target processes / 484 fixture assertions**, **48 worker lifecycle cycles**, both native standalone modes and both registered IntelliJ modes. Registered IDE checks also verified exclusive JavaFX 26 natives and worker cleanup. The source file rename received a final whitespace-only formatter adjustment after the build; no behavior changed. No manual testing was used, and previously skipped checks remain skipped.
Local evidence: `/tmp/computed-bootstrap.log`, `/tmp/computed-registered-ide.log`, `target/connected-workflow/` and `target/ide-workflow/{classpath,modulepath}.json`. Malformed-XML diagnostics are deliberate recovery-test inputs; no new native crash or uncaught application exception was found. This qualifies the pinned baseline, not additional OS/IDE versions.
+2 -2
View File
@@ -10,13 +10,13 @@ A secure DOM identifies declarations; a lexical range index edits only the relev
Existing DirectBindings written with attributes are editable. Declarations using nested property elements remain visible and can be explicitly removed, but must be changed in Source. This does not flatten or guess unsupported syntax. Short class names require the library import; conflicting explicit imports are not treated as library declarations. Existing DirectBindings written with attributes are editable. Declarations using nested property elements remain visible and can be explicitly removed, but must be changed in Source. This does not flatten or guess unsupported syntax. Short class names require the library import; conflicting explicit imports are not treated as library declarations.
A target occupied by another DirectBinding or ComputedBinding requires **Replace conflicting bindings**. Replacement removes those conflicting declarations and adds/changes the chosen binding in one undo command. ComputedBinding authoring is not included. A target occupied by another DirectBinding or ComputedBinding requires **Replace conflicting bindings**. Replacement removes those conflicting declarations and adds/changes the chosen binding in one undo command. The [Computed-binding inspector](computed-binding-inspector.md) uses the same editing and conflict machinery.
## Validation boundary ## Validation boundary
Structural diagnostics cover duplicate targets, missing/ambiguous fx:id, empty property, malformed/missing Model path and invalid direction. [Converter discovery and binding validation](converter-validation.md) now add target-side path/property/type and capability checks, a shared chooser, and automatic validation before new previews. Saving and source repair remain available on errors. Structural diagnostics cover duplicate targets, missing/ambiguous fx:id, empty property, malformed/missing Model path and invalid direction. [Converter discovery and binding validation](converter-validation.md) now add target-side path/property/type and capability checks, a shared chooser, and automatic validation before new previews. Saving and source repair remain available on errors.
[Custom ViewModel discovery and Computed validation](viewmodel-computed-validation.md) are implemented. Generated metadata, Computed/Repeat/Table authoring, complete semantic diagnostics and identifier rename/copy/subtree-delete reference repair remain later stage-4 work. [Custom ViewModel discovery and Computed validation](viewmodel-computed-validation.md) are implemented. Generated metadata, Repeat/Table authoring, complete semantic diagnostics and identifier rename/copy/subtree-delete reference repair remain later stage-4 work.
## Automated qualification ## Automated qualification
+4
View File
@@ -113,3 +113,7 @@ The [next shared stage-4 slice](converter-validation.md) adds optional concrete-
## Custom ViewModels and Computed bindings ## Custom ViewModels and Computed bindings
The [shared signature-based slice](viewmodel-computed-validation.md) implements explicit Controller/Component ViewModel selection, subclass/replacement property discovery and Computed-method validation. Generated metadata, cross-View selection conflicts, full collection authoring and Problems integration remain open. The [shared signature-based slice](viewmodel-computed-validation.md) implements explicit Controller/Component ViewModel selection, subclass/replacement property discovery and Computed-method validation. Generated metadata, cross-View selection conflicts, full collection authoring and Problems integration remain open.
## Shared Computed-binding inspector
The [Computed authoring slice](computed-binding-inspector.md) adds create/change/remove and explicit conflict replacement in both hosts through the same source-preserving editor as Direct bindings. Target method choices prepare the form without changing source; normal host Undo/Redo owns each operation. Full collection authoring, generated metadata, reference refactoring and Problems integration remain open.
+2 -2
View File
@@ -1,6 +1,6 @@
# Custom ViewModels and Computed bindings # Custom ViewModels and Computed bindings
The shared **Bindings → Discover targets** panel now identifies the declared Model, selected ViewModel and Controller, lists custom properties, and shows public zero-argument application methods as Computed sources. **Find Converters / Validate** and the automatic preview gate validate existing Direct and Computed declarations in both hosts. This slice validates Computed bindings edited in Source; it does not add a Computed-binding authoring form. The shared **Bindings → Discover targets** panel now identifies the declared Model, selected ViewModel and Controller, lists custom properties, and shows public zero-argument application methods as Computed sources. **Find Converters / Validate** and the automatic preview gate validate existing Direct and Computed declarations in both hosts. The subsequent [Computed-binding inspector](computed-binding-inspector.md) also uses this validation for declarations authored in the shared form.
## Runtime selection and property precedence ## Runtime selection and property precedence
@@ -22,7 +22,7 @@ Computed validation still runs when a Model declaration is missing: that limits
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 `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.
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 and a Computed authoring form remain later work. 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.
## Automated qualification ## Automated qualification