Add shared source-preserving DirectBinding inspector
This commit is contained in:
@@ -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) {
|
||||
|
||||
+146
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user