Add shared source-preserving DirectBinding inspector

This commit is contained in:
2026-11-13 12:35:20 +01:00
parent 2fe408bb4d
commit 17b1bce704
11 changed files with 939 additions and 12 deletions
@@ -6,7 +6,7 @@ import javafx.beans.property.ReadOnlyStringWrapper;
import javafx.scene.control.*;
import javafx.scene.layout.*;
/** Shared read-only first slice of the binding inspector. */
/** Shared target discovery with explicit handoff to the binding editor. */
final class BindingBrowser extends VBox {
final ComboBox<BindingMetadata.Attribute> paths = new ComboBox<>();
final ComboBox<String> directions = new ComboBox<>();
@@ -14,7 +14,8 @@ final class BindingBrowser extends VBox {
final Label status = new Label("Build and Refresh, then discover binding targets.");
private boolean updating;
BindingBrowser(BiConsumer<String, String> discover) {
BindingBrowser(
BiConsumer<String, String> discover, Consumer<DirectBindingDocument.Values> create) {
setSpacing(5);
paths.setId("binding-path");
directions.setId("binding-direction");
@@ -25,6 +26,20 @@ final class BindingBrowser extends VBox {
directions.setValue("both");
Button refresh = new Button("Discover bindings");
refresh.setOnAction(e -> discover.accept(path(), directions.getValue()));
Button use = new Button("Use selected target");
use.setDisable(true);
targets
.getSelectionModel()
.selectedItemProperty()
.addListener((o, a, b) -> use.setDisable(b == null));
use.setOnAction(
e -> {
var target = targets.getSelectionModel().getSelectedItem();
if (target != null)
create.accept(
new DirectBindingDocument.Values(
target.control(), target.property(), path(), directions.getValue(), ""));
});
paths.setOnAction(
e -> {
if (!updating) discover.accept(path(), directions.getValue());
@@ -46,7 +61,8 @@ final class BindingBrowser extends VBox {
new HBox(
8, refresh, new Label("Model path"), paths, new Label("Direction"), directions),
status,
targets);
targets,
use);
}
private String path() {
@@ -42,6 +42,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
var hit = kit.editor().getSelection().getHitItem();
if (hit == null) return;
String id = hit.getFxId();
if (bindingEditor != null) bindingEditor.selectControl(id);
if (id == null || !projection.components().containsKey(id)) return;
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
componentInspector
@@ -113,6 +114,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private DesignerWorkflow interactiveWorkflow;
private DesignerWorkflow bindingWorkflow;
private BindingBrowser bindingBrowser;
private DirectBindingEditor bindingEditor;
private Tab directBindingTab;
private long bindingRequest;
private boolean interactiveRequested, interactiveRestart;
private long interactiveRequest;
@@ -172,7 +175,21 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
launch.maven(),
launch.tools(),
this::log);
bindingBrowser = new BindingBrowser(this::discoverBindings);
bindingEditor =
new DirectBindingEditor(
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);
});
bindingBrowser =
new BindingBrowser(
this::discoverBindings,
values -> {
bindingEditor.newBinding(values);
directBindingTab.getTabPane().getSelectionModel().select(directBindingTab);
});
interactiveWorkflow.onInteractiveClosed(
() ->
fx(
@@ -308,7 +325,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
var properties = new ScrollPane(componentInspector);
properties.setFitToWidth(true);
properties.setPrefViewportHeight(100);
var bindings = new TitledPane("Bindings — discovery", bindingBrowser);
var discoveryTab = new Tab("Discover targets", bindingBrowser);
discoveryTab.setClosable(false);
directBindingTab = new Tab("Direct bindings", bindingEditor);
directBindingTab.setClosable(false);
var bindings = new TitledPane("Bindings", new TabPane(directBindingTab, discoveryTab));
bindings.setExpanded(false);
center.setBottom(new VBox(properties, bindings));
output.setEditable(false);
@@ -448,6 +469,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
}
}
draft = value;
bindingEditor.load(value.text(), value.writable());
submittedSource = null;
commitSource();
return;
@@ -469,6 +491,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
var hit = kit.editor().getSelection().getHitItem();
String selectedId = hit == null ? null : hit.getFxId();
draft = value;
bindingEditor.load(value.text(), value.writable());
if (!source.getText().equals(value.text())) {
int anchor = source.getAnchor(), caret = source.getCaretPosition();
var edits =
@@ -620,6 +643,45 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
bindingBrowser.invalidate(message);
}
private void editBinding(DirectBindingEditor.Intent intent) {
withDraft(
() -> {
try {
if (!draft.writable()) throw new IllegalStateException("Document is read-only");
if (!draft.text().equals(intent.source()))
throw new IllegalStateException("Document changed; select the binding again");
var bindings = new DirectBindingDocument(draft.text());
var edits =
intent.remove()
? bindings.remove(java.util.Objects.requireNonNull(intent.index()))
: bindings.write(intent.index(), intent.values(), intent.replace());
submit(
edits,
intent.remove()
? "Remove DirectBinding"
: intent.index() == null ? "Create DirectBinding" : "Edit DirectBinding");
} catch (Exception error) {
bindingEditor.error(error.getMessage());
}
});
}
private boolean bindingPreviewAllowed() {
try {
var errors = new DirectBindingDocument(draft.text()).errors();
if (errors.isEmpty()) return true;
status.setText("Preview blocked: " + String.join("; ", errors));
if (projection != null)
for (String id : projection.components().keySet()) {
var region = proxy(id);
if (region != null) region.show(images.get(id), "Binding errors; preview outdated");
}
} catch (Exception error) {
report(error);
}
return false;
}
private void discoverBindings(String path, String direction) {
withDraft(
() -> {
@@ -740,6 +802,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
public void render() {
withDraft(
() -> {
if (!bindingPreviewAllowed()) return;
var selected = examples.getValue();
if (selected == null || projection == null) return;
long ticket = renderSequence.incrementAndGet();
@@ -0,0 +1,268 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import java.util.*;
import org.w3c.dom.*;
/** Direct binding edit intents translated into lexical ranges in one immutable source revision. */
final class DirectBindingDocument {
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) {}
record Entry(int index, Values values, boolean editable) {
public String toString() {
return values.control() + "." + values.property() + " ← " + values.path();
}
}
static final class Conflict extends IllegalArgumentException {
Conflict(String target) {
super(
"Bindings already target "
+ target
+ ". Select Replace conflicting bindings to replace them in this action.");
}
}
final String text;
final Document document;
final XmlSource source;
final List<Element> elements = new ArrayList<>();
final List<Entry> entries = new ArrayList<>();
private final List<String> imports = new ArrayList<>();
private final Map<Element, Integer> indexes = new IdentityHashMap<>();
DirectBindingDocument(String text) throws Exception {
this.text = text;
document = FxmlProjection.parse(text);
source = new XmlSource(text);
for (Node n = document.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import"))
imports.add(pi.getData().trim());
var nodes = document.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
elements.add(e);
indexes.put(e, i);
}
if (elements.size() != source.tags.size())
throw new IllegalArgumentException("XML source ranges do not match parsed elements");
for (var e : elements)
if (binding(e, "DirectBinding"))
entries.add(new Entry(indexes.get(e), values(e), !hasElementChildren(e)));
}
private boolean named(Element e, String type) {
if (e.getTagName().equals(PACKAGE + type)) return true;
if (!e.getTagName().equals(type)) return false;
boolean own = imports.contains(PACKAGE + type) || imports.contains(PACKAGE + "*");
boolean other =
imports.stream().anyMatch(i -> i.endsWith("." + type) && !i.equals(PACKAGE + type));
return own && !other;
}
private boolean list(Element e) {
return named(e, "BindingList")
&& e.getParentNode() instanceof Element p
&& FX.equals(p.getNamespaceURI())
&& p.getLocalName().equals("define")
&& p.getParentNode() == document.getDocumentElement();
}
private Element bindingList(Element e) {
Node parent = e.getParentNode();
if (parent instanceof Element p && p.getTagName().equals("elements"))
parent = p.getParentNode();
return parent instanceof Element p && list(p) ? p : null;
}
private boolean binding(Element e, String name) {
return named(e, name) && bindingList(e) != null;
}
private static Values values(Element e) {
return new Values(
e.getAttribute("control"),
e.getAttribute("property"),
e.getAttribute("path"),
e.getAttribute("direction"),
e.getAttribute("converter"));
}
private static boolean hasElementChildren(Element e) {
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element) return true;
return false;
}
private Element entry(int index) {
return entries.stream()
.filter(e -> e.index() == index)
.findFirst()
.map(e -> elements.get(index))
.orElseThrow(() -> new IllegalArgumentException("Binding selection is no longer valid"));
}
List<String> errors() {
var errors = new ArrayList<String>();
var ids = new HashMap<String, Integer>();
for (var e : elements) {
String id = e.getAttributeNS(FX, "id");
if (!id.isEmpty()) ids.merge(id, 1, Integer::sum);
}
var occupied = new HashSet<String>();
for (var e : elements)
if (binding(e, "DirectBinding") || binding(e, "ComputedBinding")) {
String target = e.getAttribute("control") + "." + e.getAttribute("property");
if (!occupied.add(target)) errors.add("Duplicate binding target: " + target);
if (!hasElementChildren(e)) {
if (ids.getOrDefault(e.getAttribute("control"), 0) != 1)
errors.add("Missing or ambiguous control: " + e.getAttribute("control"));
if (e.getAttribute("property").isBlank()) errors.add("Missing property: " + target);
if (binding(e, "DirectBinding")) {
if (!e.getAttribute("path")
.matches(
"[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*(\\.[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*)*"))
errors.add("Invalid or missing Model path: " + target);
if (!Set.of("view", "model", "both").contains(e.getAttribute("direction")))
errors.add("Invalid or missing direction: " + target);
}
}
}
return List.copyOf(errors);
}
List<Replacement> remove(int index) {
Element e = entry(index);
var t = source.tags.get(indexes.get(e));
return List.of(new Replacement(t.start(), t.end(), ""));
}
List<Replacement> write(Integer index, Values value, boolean replaceConflicts) {
if (value.control().isBlank() || value.property().isBlank())
throw new IllegalArgumentException("Choose a control and property");
Element original = index == null ? null : entry(index);
if (original != null && hasElementChildren(original))
throw new IllegalArgumentException(
"This declaration uses nested property elements; edit it in Source.");
var conflicts =
elements.stream()
.filter(
e ->
e != original
&& (binding(e, "DirectBinding") || binding(e, "ComputedBinding"))
&& e.getAttribute("control").equals(value.control())
&& e.getAttribute("property").equals(value.property()))
.toList();
if (!conflicts.isEmpty() && !replaceConflicts)
throw new Conflict(value.control() + "." + value.property());
var edits = new ArrayList<Replacement>();
for (var e : conflicts) {
var t = source.tags.get(indexes.get(e));
edits.add(new Replacement(t.start(), t.end(), ""));
}
if (original != null) {
var changed = new LinkedHashMap<String, String>();
Map<String, String> fields =
Map.of(
"control",
value.control(),
"property",
value.property(),
"path",
value.path(),
"direction",
value.direction(),
"converter",
value.converter());
for (var f : fields.entrySet())
if (!original.getAttribute(f.getKey()).equals(f.getValue()))
changed.put(
f.getKey(),
f.getValue().isEmpty() && f.getKey().equals("converter") ? null : f.getValue());
edits.addAll(source.attributes(index, changed));
} else {
String node =
"<"
+ PACKAGE
+ "DirectBinding control=\""
+ XmlSource.escape(value.control(), '"')
+ "\" property=\""
+ XmlSource.escape(value.property(), '"')
+ "\" path=\""
+ XmlSource.escape(value.path(), '"')
+ "\" direction=\""
+ XmlSource.escape(value.direction(), '"')
+ "\""
+ (value.converter().isEmpty()
? ""
: " converter=\"" + XmlSource.escape(value.converter(), '"') + "\"")
+ "/>";
var lists = elements.stream().filter(this::list).toList();
if (!lists.isEmpty()) {
Element container = lists.getFirst();
for (Node n = container.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element e && e.getTagName().equals("elements")) {
container = e;
break;
}
edits.add(append(container, node, ""));
} else {
String body =
"<"
+ PACKAGE
+ "BindingList>"
+ newline()
+ " "
+ node
+ newline()
+ "</"
+ PACKAGE
+ "BindingList>";
Element root = document.getDocumentElement();
Element define =
elements.stream()
.filter(
e ->
FX.equals(e.getNamespaceURI())
&& e.getLocalName().equals("define")
&& e.getParentNode() == root)
.findFirst()
.orElse(null);
if (define != null) edits.add(append(define, body, ""));
else {
String prefix = root.lookupPrefix(FX), attribute = "";
if (prefix == null) {
prefix = "fx";
int suffix = 1;
while (root.lookupNamespaceURI(prefix) != null) prefix = "fx" + (suffix++);
attribute = " xmlns:" + prefix + "=\"" + FX + "\"";
}
body =
"<" + prefix + ":define>" + newline() + body + newline() + "</" + prefix + ":define>";
var tag = source.tags.get(indexes.get(root));
if (!attribute.isEmpty() && !tag.selfClosing())
edits.add(new Replacement(tag.insert(), tag.insert(), attribute));
edits.add(append(root, body, tag.selfClosing() ? attribute : ""));
}
}
}
return List.copyOf(edits);
}
private String newline() {
return text.contains("\r\n") ? "\r\n" : "\n";
}
private Replacement append(Element container, String child, String attributes) {
var t = source.tags.get(indexes.get(container));
String eol = newline();
if (t.selfClosing())
return new Replacement(
t.insert(),
t.openEnd(),
attributes + ">" + eol + child + eol + "</" + container.getTagName() + ">");
return new Replacement(t.closeStart(), t.closeStart(), eol + child + eol);
}
}
@@ -0,0 +1,168 @@
package de.neitzel.fxdesigner.kit;
import java.util.function.*;
import javafx.scene.control.*;
import javafx.scene.layout.*;
/** View-wide declarations and an explicit, revision-bound edit form shared by both hosts. */
final class DirectBindingEditor extends VBox {
record Intent(
String source,
Integer index,
DirectBindingDocument.Values values,
boolean remove,
boolean replace) {}
private final ListView<DirectBindingDocument.Entry> bindings = new ListView<>();
private final TextField control = field("control"),
property = field("property"),
path = field("path"),
converter = field("converter");
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.");
private final Button fresh = new Button("New binding"),
apply = new Button("Apply binding"),
remove = new Button("Remove binding");
private String source = "";
private Integer selected;
private boolean loading, writable, active, followingCanvas;
private final Consumer<String> navigate;
DirectBindingEditor(Consumer<Intent> edit, Consumer<String> navigate) {
this.navigate = navigate;
setSpacing(5);
bindings.setId("direct-bindings");
bindings.setPrefHeight(100);
direction.setId("direct-direction");
direction.getItems().setAll("view", "model", "both");
direction.setValue("both");
replace.setId("direct-replace");
status.setId("direct-status");
status.setWrapText(true);
fresh.setOnAction(e -> newBinding(new DirectBindingDocument.Values("", "", "", "both", "")));
apply.setOnAction(
e -> edit.accept(new Intent(source, selected, values(), false, replace.isSelected())));
remove.setOnAction(e -> edit.accept(new Intent(source, selected, values(), true, false)));
bindings
.getSelectionModel()
.selectedItemProperty()
.addListener(
(o, a, b) -> {
if (loading || b == null) return;
selected = b.index();
active = b.editable();
fill(b.values());
replace.setSelected(false);
buttons();
if (!followingCanvas) navigate.accept(b.values().control());
if (!b.editable())
status.setText(
"Nested property elements are preserved. Edit this declaration in Source, or"
+ " remove it explicitly.");
});
var grid = new GridPane();
grid.setHgap(8);
grid.setVgap(4);
grid.addRow(
0,
new Label("Control (fx:id)"),
control,
new Label("Property"),
property,
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);
buttons();
}
private static TextField field(String name) {
var f = new TextField();
f.setId("direct-" + name);
f.setPrefColumnCount(14);
return f;
}
private DirectBindingDocument.Values values() {
return new DirectBindingDocument.Values(
control.getText().trim(),
property.getText().trim(),
path.getText().trim(),
direction.getValue(),
converter.getText().trim());
}
private void fill(DirectBindingDocument.Values v) {
control.setText(v.control());
property.setText(v.property());
path.setText(v.path());
direction.setValue(v.direction());
converter.setText(v.converter());
}
void newBinding(DirectBindingDocument.Values v) {
loading = true;
bindings.getSelectionModel().clearSelection();
loading = false;
selected = null;
active = true;
fill(v);
replace.setSelected(false);
buttons();
}
private void buttons() {
fresh.setDisable(!writable);
apply.setDisable(!writable || !active);
remove.setDisable(!writable || selected == null);
}
void load(String text, boolean editable) {
if (source.equals(text) && writable == editable) return;
source = text;
writable = editable;
loading = true;
selected = null;
active = false;
try {
var doc = new DirectBindingDocument(text);
bindings.getItems().setAll(doc.entries);
bindings.getSelectionModel().clearSelection();
status.setText(
doc.errors().isEmpty()
? "Select a DirectBinding or choose New binding. Converter types are not yet"
+ " verified."
: String.join("\n", doc.errors()));
} catch (Exception error) {
bindings.getItems().clear();
writable = false;
status.setText("Binding editing unavailable: " + error.getMessage());
} finally {
loading = false;
buttons();
}
}
void selectControl(String id) {
if (loading || id == null) return;
var selected = bindings.getSelectionModel().getSelectedItem();
if (selected != null && selected.values().control().equals(id)) return;
bindings.getItems().stream()
.filter(e -> e.values().control().equals(id))
.findFirst()
.ifPresent(
e -> {
followingCanvas = true;
try {
bindings.getSelectionModel().select(e);
} finally {
followingCanvas = false;
}
});
}
void error(String message) {
status.setText(message);
}
}
@@ -9,7 +9,14 @@ import java.util.regex.*;
final class XmlSource {
record Attribute(int start, int end, int valueStart, int valueEnd, char quote) {}
record Tag(int insert, Map<String, Attribute> attributes) {}
record Tag(
int start,
int insert,
int openEnd,
int closeStart,
int end,
boolean selfClosing,
Map<String, Attribute> attributes) {}
private static final Pattern ATTRIBUTE =
Pattern.compile("([^\\s=<>/]+)\\s*=\\s*(['\"])(.*?)\\2", Pattern.DOTALL);
@@ -19,6 +26,7 @@ final class XmlSource {
XmlSource(String text) throws IOException {
this.text = text;
int i = 0;
var stack = new ArrayDeque<Integer>();
while ((i = text.indexOf('<', i)) >= 0) {
String end =
text.startsWith("<!--", i)
@@ -53,10 +61,24 @@ final class XmlSource {
matcher.start(3),
matcher.end(3),
matcher.group(2).charAt(0)));
tags.add(new Tag(text.charAt(i - 1) == '/' ? i - 1 : i, attrs));
boolean self = text.charAt(i - 1) == '/';
int index = tags.size();
tags.add(
new Tag(
start, self ? i - 1 : i, i + 1, self ? i - 1 : -1, self ? i + 1 : -1, self, attrs));
if (!self) stack.push(index);
} else {
if (stack.isEmpty()) throw new IOException("Unbalanced XML closing tag");
int index = stack.pop();
var tag = tags.get(index);
tags.set(
index,
new Tag(
tag.start(), tag.insert(), tag.openEnd(), start, i + 1, false, tag.attributes()));
}
i++;
}
if (!stack.isEmpty()) throw new IOException("Unclosed XML element");
}
List<Replacement> attributes(int index, Map<String, String> values) {
@@ -0,0 +1,146 @@
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/>"));
}
}
@@ -207,6 +207,199 @@ public final class ProjectionHostChecks {
await(() -> fx(() -> source(pane).getText().equals(before)));
}
private static void bindingTab(DesignerPane pane, String name) throws Exception {
fx(
() -> {
pane.lookupAll(".tab-pane").stream()
.filter(n -> n instanceof TabPane)
.map(n -> (TabPane) n)
.forEach(
t ->
t.getTabs().stream()
.filter(tab -> tab.getText().equals(name))
.findFirst()
.ifPresent(tab -> t.getSelectionModel().select(tab)));
return null;
});
}
private static void directField(DesignerPane pane, String field, String value) throws Exception {
fx(
() -> {
((TextField) pane.lookup("#direct-" + field)).setText(value);
return null;
});
}
private static void selectDirect(DesignerPane pane, String control) throws Exception {
fx(
() -> {
var list = (ListView<Object>) pane.lookup("#direct-bindings");
list.getSelectionModel()
.select(
list.getItems().stream()
.filter(e -> e.toString().startsWith(control + "."))
.findFirst()
.orElseThrow());
return null;
});
}
// The live IDE probe uses a separate plugin loader: assert exported UI and XML, not internals.
private static List<org.w3c.dom.Element> directDeclarations(String text) throws Exception {
var factory = javax.xml.parsers.DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
var parsed =
factory
.newDocumentBuilder()
.parse(new org.xml.sax.InputSource(new java.io.StringReader(text)));
var nodes = parsed.getElementsByTagName("*");
var result = new ArrayList<org.w3c.dom.Element>();
for (int i = 0; i < nodes.getLength(); i++) {
var element = (org.w3c.dom.Element) nodes.item(i);
if (Set.of("DirectBinding", "de.neitzel.fx.view.DirectBinding")
.contains(element.getTagName())) result.add(element);
}
return result;
}
private static String documentText(DocumentSession doc) throws Exception {
return doc.snapshot().toCompletableFuture().get().text();
}
private static void directBindingEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
bindingTab(pane, "Direct bindings");
click(pane, "New binding");
directField(pane, "control", "action");
directField(pane, "property", "text");
directField(pane, "path", "name");
fx(
() -> {
((ComboBox<String>) pane.lookup("#direct-direction")).setValue("view");
return null;
});
click(pane, "Apply binding");
await(() -> !documentText(document).equals(full));
String created = documentText(document);
check(
directDeclarations(created).stream()
.anyMatch(
e ->
e.getAttribute("control").equals("action")
&& e.getAttribute("path").equals("name")),
"Create binding failed");
click(pane, "Undo");
await(() -> documentText(document).equals(full));
click(pane, "Redo");
await(() -> documentText(document).equals(created));
await(() -> fx(() -> source(pane).getText().equals(created)));
selectDirect(pane, "action");
directField(pane, "path", "home.street");
click(pane, "Apply binding");
String changed =
created.replace(
"path=\"name\" direction=\"view\"", "path=\"home.street\" direction=\"view\"");
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),
"Binding save changed source");
click(pane, "Render drafts");
await(
() ->
fx(
() ->
object(pane, "home").getSceneGraphObject() instanceof SnapshotRegion r
&& r.image() != null
&& r.statusText().equals("Target runtime")));
selectDirect(pane, "action");
click(pane, "Remove binding");
await(() -> !documentText(document).equals(changed));
String removed = documentText(document);
check(
directDeclarations(removed).stream()
.noneMatch(e -> e.getAttribute("control").equals("action")),
"Remove binding failed");
click(pane, "Undo");
await(() -> documentText(document).equals(changed));
click(pane, "Redo");
await(() -> documentText(document).equals(removed));
await(() -> fx(() -> source(pane).getText().equals(removed)));
// Conflicting creation requires explicit replacement and remains one history entry.
click(pane, "New binding");
directField(pane, "control", "rankInput");
directField(pane, "property", "text");
directField(pane, "path", "name");
fx(
() -> {
((ComboBox<String>) pane.lookup("#direct-direction")).setValue("view");
return null;
});
click(pane, "Apply binding");
check(documentText(document).equals(removed), "Conflict silently overwrote a binding");
fx(
() -> {
((CheckBox) pane.lookup("#direct-replace")).setSelected(true);
return null;
});
click(pane, "Apply binding");
await(() -> !documentText(document).equals(removed));
String replaced = documentText(document);
var rank =
directDeclarations(replaced).stream()
.filter(e -> e.getAttribute("control").equals("rankInput"))
.toList();
check(
rank.size() == 1 && rank.getFirst().getAttribute("path").equals("name"),
"Explicit replace failed");
click(pane, "Undo");
await(() -> documentText(document).equals(removed));
click(pane, "Redo");
await(() -> documentText(document).equals(replaced));
await(() -> fx(() -> source(pane).getText().equals(replaced)));
selectDirect(pane, "rankInput");
directField(pane, "path", "invalid path");
click(pane, "Apply binding");
await(() -> documentText(document).contains("path=\"invalid path\""));
String invalid = documentText(document);
await(() -> fx(() -> source(pane).getText().equals(invalid)));
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(invalid),
"Invalid binding could not be saved");
click(pane, "Render drafts");
await(
() ->
fx(
() ->
pane.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& l.getText() != null
&& l.getText().startsWith("Preview blocked:"))));
click(pane, "Undo");
await(() -> documentText(document).equals(replaced));
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
}
public static void run(DesignerPane pane, DocumentSession document) throws Exception {
String original = document.snapshot().toCompletableFuture().get().text();
// The same real application, now with nested, fully-qualified Component declarations.
@@ -429,11 +622,12 @@ public final class ProjectionHostChecks {
fx(
() -> {
pane.lookupAll(".titled-pane").stream()
.filter(n -> n instanceof TitledPane t && t.getText().equals("Bindings — discovery"))
.filter(n -> n instanceof TitledPane t && t.getText().equals("Bindings"))
.map(n -> (TitledPane) n)
.forEach(t -> t.setExpanded(true));
return null;
});
bindingTab(pane, "Discover targets");
click(pane, "Discover bindings");
await(
() ->
@@ -481,6 +675,29 @@ public final class ProjectionHostChecks {
check(
document.snapshot().toCompletableFuture().get().text().equals(full),
"Binding discovery altered source");
fx(
() -> {
var targets =
(TableView<de.neitzel.fxdesigner.protocol.BindingMetadata.Target>)
pane.lookup("#binding-targets");
targets
.getSelectionModel()
.select(
targets.getItems().stream()
.filter(t -> t.control().equals("rankInput") && t.property().equals("text"))
.findFirst()
.orElseThrow());
return null;
});
click(pane, "Use selected target");
check(
fx(
() ->
((TextField) pane.lookup("#direct-control")).getText().equals("rankInput")
&& ((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");
directBindingEdits(pane, document, full);
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()