Add reference-safe FXML rename, subtree copy and delete

This commit is contained in:
2026-11-13 16:13:03 +01:00
parent 5462f480a4
commit 910a8b0e85
13 changed files with 1191 additions and 7 deletions
@@ -46,6 +46,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
if (computedEditor != null) computedEditor.selectControl(id);
if (repeatEditor != null) repeatEditor.selectControl(id);
if (tableEditor != null) tableEditor.selectControl(id);
if (referenceEditor != null) referenceEditor.selectIndex(projection.sourceIndex(id));
if (id == null || !projection.components().containsKey(id)) return;
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
componentInspector
@@ -119,6 +120,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private BindingBrowser bindingBrowser;
private BindingEditor bindingEditor, computedEditor, repeatEditor;
private TableEditor tableEditor;
private ReferenceEditor referenceEditor;
private String pendingReferenceValidation;
private String validatedSource, validatingSource;
private boolean validationPreview;
private java.util.List<de.neitzel.fxdesigner.protocol.BindingMetadata.Problem> bindingProblems =
@@ -216,6 +219,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
tableEditor =
new TableEditor(
this::editTable, this::navigateComponent, values -> validateBindings(values, false));
referenceEditor = new ReferenceEditor(file.toUri().toString(), this::editStructure);
bindingBrowser =
new BindingBrowser(
this::discoverBindings,
@@ -368,10 +372,13 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
repeatTab.setClosable(false);
var tableTab = new Tab("Table / Columns", tableEditor);
tableTab.setClosable(false);
var structureTab = new Tab("Structure", referenceEditor);
structureTab.setClosable(false);
var bindings =
new TitledPane(
"Bindings",
new TabPane(directBindingTab, computedTab, repeatTab, tableTab, discoveryTab));
new TabPane(
directBindingTab, computedTab, repeatTab, tableTab, structureTab, discoveryTab));
bindings.setExpanded(false);
center.setBottom(new VBox(properties, bindings));
output.setEditable(false);
@@ -515,6 +522,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
computedEditor.load(value.text(), value.writable());
repeatEditor.load(value.text(), value.writable());
tableEditor.load(value.text(), value.writable());
referenceEditor.load(value.text(), value.writable());
submittedSource = null;
commitSource();
return;
@@ -540,6 +548,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
computedEditor.load(value.text(), value.writable());
repeatEditor.load(value.text(), value.writable());
tableEditor.load(value.text(), value.writable());
referenceEditor.load(value.text(), value.writable());
if (!source.getText().equals(value.text())) {
int anchor = source.getAnchor(), caret = source.getCaretPosition();
var edits =
@@ -605,6 +614,10 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
report(error);
} finally {
loading = false;
if (value.text().equals(pendingReferenceValidation)) {
pendingReferenceValidation = null;
Platform.runLater(() -> validateBindings(null, false));
}
}
}
@@ -696,6 +709,22 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
tableEditor.invalidateDiscovery();
}
private void editStructure(ReferenceEditor.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 source object again");
if (intent.validate())
pendingReferenceValidation = apply(draft.text(), intent.change().edits());
submit(intent.change().edits(), intent.label());
} catch (Exception e) {
referenceEditor.error(e.getMessage());
}
});
}
private void editTable(TableEditor.Intent intent) {
withDraft(
() -> {
@@ -742,6 +771,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
try {
var errors = new ArrayList<>(new BindingDocument(draft.text()).errors());
errors.addAll(new TableDocument(draft.text()).errors());
errors.addAll(new ReferenceDocument(draft.text()).referenceErrors());
if (errors.isEmpty()) return true;
status.setText("Preview blocked: " + String.join("; ", errors));
if (projection != null)
@@ -92,6 +92,10 @@ public final class FxmlProjection {
return w.toString();
}
Integer sourceIndex(String id) {
return sourceNodes.get(id);
}
public Map<String, String> components() {
return Map.copyOf(components);
}
@@ -278,6 +282,14 @@ public final class FxmlProjection {
return result;
}
private static List<Element> objectElements(Document document) {
var result = new ArrayList<Element>();
var all = document.getElementsByTagName("*");
for (int i = 0; i < all.getLength(); i++)
if (isObject((Element) all.item(i))) result.add((Element) all.item(i));
return result;
}
private static boolean isObject(Element e) {
String tag = e.getTagName();
return !FX.equals(e.getNamespaceURI())
@@ -292,10 +304,38 @@ public final class FxmlProjection {
String projected) throws Exception {
var changed = parse(projected);
var before = indexed(baseline);
var after = indexed(changed);
var result = new ArrayList<de.neitzel.fxdesigner.contracts.EditBatch.Replacement>();
// Kit's identifier inspector can propose one rename. Normalize only for comparison;
// the authoritative document and its binding references are edited together below.
var oldObjects = objectElements(baseline);
var newObjects = objectElements(changed);
if (oldObjects.size() == newObjects.size()) {
int renamed = -1;
for (int i = 0; i < oldObjects.size(); i++)
if (!oldObjects
.get(i)
.getAttributeNS(FX, "id")
.equals(newObjects.get(i).getAttributeNS(FX, "id"))) {
if (renamed >= 0)
throw new IOException("Rename one identifier at a time through Structure");
renamed = i;
}
if (renamed >= 0) {
var old = oldObjects.get(renamed);
var next = newObjects.get(renamed);
String id = old.getAttributeNS(FX, "id");
if (!sourceNodes.containsKey(id)) throw new IOException("Unknown source identity");
result.addAll(
new ReferenceDocument(source.text)
.rename(sourceNodes.get(id), next.getAttributeNS(FX, "id"))
.edits());
next.setAttributeNS(FX, "fx:id", id);
}
}
var after = indexed(changed);
if (!before.keySet().equals(after.keySet()) || !structure(baseline).equals(structure(changed)))
throw new IOException("Structural editing is outside this minimal projection");
throw new IOException(
"Use Bindings → Structure for reference-safe rename, copy, paste or delete");
for (String id : before.keySet()) {
var attributes = new HashSet<String>();
for (Element element : List.of(before.get(id), after.get(id)))
@@ -0,0 +1,527 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import java.util.*;
import java.util.regex.*;
import org.w3c.dom.*;
/** Lossless structural transactions in one FXML identifier scope. */
final class ReferenceDocument {
static final String FX = BindingDocument.FX;
record Entry(int index, String id, String type) {
public String toString() {
return (id.isEmpty() ? "(anonymous #" + index + ")" : id) + " : " + type;
}
}
record Clip(String source, int index, String origin) {}
record Change(List<Replacement> edits, List<String> notes) {}
private record Target(Element binding, Element element, String attribute, String value) {}
final BindingDocument xml;
final List<Entry> entries = new ArrayList<>();
private final Map<String, Element> ids = new LinkedHashMap<>();
private final List<Target> targets = new ArrayList<>();
ReferenceDocument(String text) throws Exception {
xml = new BindingDocument(text);
for (int i = 0; i < xml.elements.size(); i++) {
var e = xml.elements.get(i);
String id = e.getAttributeNS(FX, "id");
if (!id.isEmpty() && ids.putIfAbsent(id, e) != null)
throw new IllegalArgumentException("Ambiguous fx:id: " + id);
if (!defined(e) && (object(e) || e == xml.document.getDocumentElement()))
entries.add(new Entry(i, id, e.getTagName()));
if (binding(e)) {
String name = xml.named(e, "Repeat") ? "pane" : "control";
var nested = children(e).stream().filter(c -> c.getTagName().equals(name)).toList();
if (e.hasAttribute(name) && nested.isEmpty())
targets.add(new Target(e, e, name, e.getAttribute(name)));
else if (!e.hasAttribute(name)
&& nested.size() == 1
&& children(nested.getFirst()).isEmpty())
targets.add(
new Target(e, nested.getFirst(), null, nested.getFirst().getTextContent().trim()));
else
throw new IllegalArgumentException(
"Ambiguous or unsupported binding target; edit in Source");
}
}
}
private boolean binding(Element e) {
if (!(xml.named(e, "DirectBinding")
|| xml.named(e, "ComputedBinding")
|| xml.named(e, "Repeat"))) return false;
Node parent = e.getParentNode();
if (parent instanceof Element p && p.getTagName().equals("elements"))
parent = p.getParentNode();
return parent instanceof Element list
&& xml.named(list, "BindingList")
&& list.getParentNode() instanceof Element define
&& FX.equals(define.getNamespaceURI())
&& "define".equals(define.getLocalName())
&& define.getParentNode() == xml.document.getDocumentElement();
}
private static boolean object(Element e) {
String n = e.getTagName();
return !FX.equals(e.getNamespaceURI())
&& Character.isUpperCase(n.charAt(n.lastIndexOf('.') + 1));
}
private static boolean defined(Element e) {
for (Node n = e; n instanceof Element p; n = n.getParentNode())
if (FX.equals(p.getNamespaceURI()) && "define".equals(p.getLocalName())) return true;
return false;
}
private static List<Element> children(Element e) {
var result = new ArrayList<Element>();
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element c) result.add(c);
return result;
}
private static boolean within(Element e, Element ancestor) {
for (Node n = e; n != null; n = n.getParentNode()) if (n == ancestor) return true;
return false;
}
private Element entry(int index) {
if (entries.stream().noneMatch(e -> e.index() == index))
throw new IllegalArgumentException("Select a source object again");
return xml.elements.get(index);
}
private XmlSource.Tag tag(Element e) {
return xml.source.tags.get(xml.elements.indexOf(e));
}
private List<Replacement> attributes(Element e, Map<String, String> values) {
return xml.source.attributes(xml.elements.indexOf(e), values);
}
private Replacement text(Target t, String value) {
var tag = tag(t.element());
String body = xml.text.substring(tag.openEnd(), tag.closeStart());
var matcher = Pattern.compile("<!--.*?-->|[^<]+", Pattern.DOTALL).matcher(body);
int start = -1, end = -1, consumed = 0;
while (matcher.find()) {
if (matcher.start() != consumed)
throw new IllegalArgumentException("Unsupported nested target syntax; edit in Source");
consumed = matcher.end();
if (matcher.group().startsWith("<!--") || matcher.group().isBlank()) continue;
if (start >= 0)
throw new IllegalArgumentException("Split nested target text; edit in Source");
start = matcher.start();
end = matcher.end();
while (start < end && Character.isWhitespace(body.charAt(start))) start++;
while (end > start && Character.isWhitespace(body.charAt(end - 1))) end--;
}
if (consumed != body.length() || start < 0)
throw new IllegalArgumentException("Unsupported nested target syntax; edit in Source");
return new Replacement(
tag.openEnd() + start, tag.openEnd() + end, XmlSource.escape(value, '"'));
}
private List<String> notes() {
return new ArrayList<>(
List.of(
"Java Controller fields and unfamiliar declarations are not refactored; review external"
+ " references."));
}
Change rename(int index, String name) {
Element e = entry(index);
String old = e.getAttributeNS(FX, "id");
if (!name.matches("[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*"))
throw new IllegalArgumentException("Enter a valid fx:id");
if (old.equals(name)) return new Change(List.of(), List.of());
if (ids.containsKey(name)) throw new IllegalArgumentException("fx:id already exists: " + name);
var edits = new ArrayList<Replacement>();
String attr =
e.hasAttributeNS(FX, "id") ? e.getAttributeNodeNS(FX, "id").getName() : fxPrefix(e) + ":id";
var attrs = new LinkedHashMap<String, String>();
attrs.put(attr, name);
String prefix = attr.substring(0, attr.indexOf(':'));
if (!FX.equals(e.lookupNamespaceURI(prefix))) attrs.put("xmlns:" + prefix, FX);
edits.addAll(attributes(e, attrs));
if (!old.isEmpty()) {
var unknown = unknownReferences(Set.of(old));
if (!unknown.isEmpty()) throw new IllegalArgumentException(String.join("; ", unknown));
edits.addAll(references(Map.of(old, name), null));
}
return new Change(List.copyOf(edits), notes());
}
Change delete(int index) {
Element selected = entry(index);
if (selected == xml.document.getDocumentElement())
throw new IllegalArgumentException("Cannot delete the View root");
var removedIds = new HashSet<String>();
ids.forEach(
(id, e) -> {
if (within(e, selected)) removedIds.add(id);
});
var remove = new LinkedHashSet<Element>();
remove.add(selected);
for (var t : targets)
if (removedIds.contains(t.value()) && !within(t.binding(), selected)) remove.add(t.binding());
var edits = new ArrayList<Replacement>();
for (var e : remove) {
var t = tag(e);
edits.add(new Replacement(t.start(), t.end(), ""));
}
var notes = notes();
notes.addAll(unknownReferences(removedIds));
notes.add(
"Removed "
+ (remove.size() - 1)
+ " associated binding declaration(s). Other dependencies remain visible for"
+ " validation.");
return new Change(List.copyOf(edits), notes);
}
Clip copy(int index, String origin) {
if (entry(index) == xml.document.getDocumentElement())
throw new IllegalArgumentException("Copy a subtree rather than the View root");
var root = entry(index);
var copiedIds = new HashSet<String>();
ids.forEach(
(id, e) -> {
if (within(e, root)) copiedIds.add(id);
});
var unknown = unknownReferences(copiedIds);
if (!unknown.isEmpty()) throw new IllegalArgumentException(String.join("; ", unknown));
if (xml.elements.stream()
.anyMatch(
e ->
within(e, root)
&& FX.equals(e.getNamespaceURI())
&& Set.of("include", "script").contains(e.getLocalName())))
throw new IllegalArgumentException(
"Subtrees with includes or scripts require Source editing");
return new Clip(xml.text, index, origin);
}
Change paste(Clip clip, int destination, String origin) throws Exception {
if (clip == null) throw new IllegalArgumentException("Copy a subtree first");
Element parent = entry(destination);
requireContainer(parent);
var from = new ReferenceDocument(clip.source());
Element selected = from.entry(clip.index());
if (!clip.origin().equals(origin))
for (var e : from.xml.elements)
if (within(e, selected))
for (int a = 0; a < e.getAttributes().getLength(); a++)
if (e.getAttributes().item(a).getNodeValue().startsWith("@"))
throw new IllegalArgumentException(
"Relative resource references require Source review for cross-View paste");
var mapping = new LinkedHashMap<String, String>();
var reserved = new HashSet<>(ids.keySet());
for (var item : from.ids.entrySet())
if (within(item.getValue(), selected)) {
String base = item.getKey() + "Copy", next = base;
int n = 2;
while (reserved.contains(next)) next = base + (n++);
reserved.add(next);
mapping.put(item.getKey(), next);
}
var edits = new ArrayList<Replacement>();
for (var id : mapping.entrySet()) {
var e = from.ids.get(id.getKey());
edits.addAll(
from.attributes(e, Map.of(e.getAttributeNodeNS(FX, "id").getName(), id.getValue())));
}
// Only references carried by the copied subtree/declarations are rewritten.
var associated =
from.targets.stream()
.filter(t -> mapping.containsKey(t.value()) && !within(t.binding(), selected))
.map(Target::binding)
.distinct()
.toList();
var carried = new ArrayList<Element>(associated);
carried.add(selected);
edits.addAll(from.references(mapping, carried));
for (var e : carried) {
var ns = new LinkedHashMap<String, String>();
for (Node n = e; n instanceof Element a; n = n.getParentNode())
for (int i = 0; i < a.getAttributes().getLength(); i++) {
var attr = a.getAttributes().item(i);
String key = attr.getNodeName();
if (key.equals("xmlns") || key.startsWith("xmlns:"))
ns.putIfAbsent(key, attr.getNodeValue());
}
ns.entrySet()
.removeIf(
v ->
Objects.equals(
v.getValue(),
parent.lookupNamespaceURI(
v.getKey().equals("xmlns") ? null : v.getKey().substring(6)))
|| e.hasAttribute(v.getKey()));
edits.addAll(from.attributes(e, ns));
}
String fragment = from.fragment(selected, edits);
String declarations =
associated.stream()
.map(e -> from.fragment(e, edits))
.collect(java.util.stream.Collectors.joining("\n"));
Element container =
children(parent).stream()
.filter(c -> c.getTagName().equals("children"))
.findFirst()
.orElse(parent);
String result = xml.source.apply(List.of(xml.append(container, fragment, "")));
var out = new ReferenceDocument(result);
if (!declarations.isEmpty()) result = out.appendBindings(declarations);
result = imports(from, result);
var notes = notes();
if (!clip.origin().equals(origin)) {
notes.add(
"Cross-View paste: validate copied bindings against the destination Model and"
+ " Controller.");
if (!model(from.xml.document).equals(model(xml.document)))
notes.add(
"Source and destination Model declarations differ; copied Model paths are unresolved"
+ " until destination validation.");
if (!from.xml
.document
.getDocumentElement()
.getAttributeNS(FX, "controller")
.equals(xml.document.getDocumentElement().getAttributeNS(FX, "controller")))
notes.add(
"Source and destination Controllers differ; copied methods require destination"
+ " validation.");
}
notes.add("Assigned identifiers: " + mapping);
return new Change(difference(xml.text, result), List.copyOf(notes));
}
private String fragment(Element e, List<Replacement> edits) {
var t = tag(e);
var local =
edits.stream()
.filter(r -> r.start() >= t.start() && r.end() <= t.end())
.map(r -> new Replacement(r.start() - t.start(), r.end() - t.start(), r.text()))
.toList();
var sorted = new ArrayList<>(local);
sorted.sort(Comparator.comparingInt(Replacement::start).reversed());
var result = new StringBuilder(xml.text.substring(t.start(), t.end()));
for (var r : sorted) result.replace(r.start(), r.end(), r.text());
return result.toString();
}
private List<Replacement> references(Map<String, String> mapping, List<Element> carried) {
var edits = new ArrayList<Replacement>();
for (var t : targets)
if (mapping.containsKey(t.value())
&& (carried == null || carried.stream().anyMatch(e -> within(t.binding(), e)))) {
String next = mapping.get(t.value());
if (t.attribute() == null) edits.add(text(t, next));
else edits.addAll(attributes(t.element(), Map.of(t.attribute(), next)));
}
for (var e : xml.elements) {
if (carried != null && carried.stream().noneMatch(a -> within(e, a))) continue;
if (FX.equals(e.getNamespaceURI()) && "script".equals(e.getLocalName()))
throw new IllegalArgumentException(
"Script references require Source review before refactoring");
var attrs = new LinkedHashMap<String, String>();
for (int i = 0; i < e.getAttributes().getLength(); i++) {
var a = e.getAttributes().item(i);
String name = a.getNodeName(), value = a.getNodeValue();
if ((FX.equals(e.getNamespaceURI())
&& Set.of("reference", "copy").contains(e.getLocalName())
&& name.equals("source"))) {
if (mapping.containsKey(value)) attrs.put(name, mapping.get(value));
} else if (value.startsWith("$") && !FX.equals(a.getNamespaceURI())) {
String key = variable(value);
if (key != null && mapping.containsKey(key))
attrs.put(
name,
value.startsWith("${")
? "${" + mapping.get(key) + value.substring(2 + key.length())
: "$" + mapping.get(key));
else if (key == null
&& mapping.keySet().stream()
.anyMatch(
k ->
Pattern.compile("(?<![\\w$])" + Pattern.quote(k) + "(?![\\w$])")
.matcher(value)
.find()))
throw new IllegalArgumentException(
"Complex expression references require Source review: " + name);
}
}
if (!attrs.isEmpty()) edits.addAll(attributes(e, attrs));
}
return edits;
}
private List<String> unknownReferences(Set<String> names) {
var notes = new ArrayList<String>();
for (var e : xml.elements)
if (targets.stream().noneMatch(t -> t.binding() == e))
for (String attr : List.of("control", "pane", "target"))
if (names.contains(e.getAttribute(attr)))
notes.add(
"Unsupported reference on " + e.getTagName() + "." + attr + "; review in Source");
return notes;
}
List<String> referenceErrors() {
var result = new ArrayList<String>();
var available = new HashSet<>(ids.keySet());
available.addAll(Set.of("controller", "resources", "location"));
for (var e : xml.elements)
for (int i = 0; i < e.getAttributes().getLength(); i++) {
var a = e.getAttributes().item(i);
if (FX.equals(a.getNamespaceURI()) || a.getNodeName().startsWith("xmlns")) continue;
String id = variable(a.getNodeValue());
if (FX.equals(e.getNamespaceURI())
&& Set.of("reference", "copy").contains(e.getLocalName())
&& a.getNodeName().equals("source")) id = a.getNodeValue();
if (id != null && !available.contains(id)) result.add("Unresolved FXML reference: " + id);
}
return List.copyOf(result);
}
private static String variable(String value) {
if (value.matches("\\$[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*"))
return value.substring(1);
if (value.matches(
"\\$\\{[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*(\\.[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*)*}"))
return value.substring(2, value.indexOf('.') > 0 ? value.indexOf('.') : value.length() - 1);
return null;
}
private static String fxPrefix(Element root) {
String p = root.lookupPrefix(FX);
if (p != null) return p;
p = "fx";
for (int n = 2; root.lookupNamespaceURI(p) != null; n++) p = "fx" + n;
return p;
}
private void requireContainer(Element e) {
String name = e.getTagName();
if (FX.equals(e.getNamespaceURI()) && "root".equals(e.getLocalName()))
name = e.getAttribute("type");
String simple = name.substring(name.lastIndexOf('.') + 1);
if (!Set.of(
"VBox",
"HBox",
"Pane",
"AnchorPane",
"StackPane",
"FlowPane",
"TilePane",
"GridPane")
.contains(simple)
|| name.contains(".") && !name.startsWith("javafx.scene.layout."))
throw new IllegalArgumentException(
"Paste into a standard Pane; other child properties require Source editing");
if (children(e).stream().filter(c -> c.getTagName().equals("children")).count() > 1)
throw new IllegalArgumentException("Ambiguous children containers");
}
private String appendBindings(String body) throws Exception {
Element root = xml.document.getDocumentElement();
Element define =
children(root).stream()
.filter(e -> FX.equals(e.getNamespaceURI()) && "define".equals(e.getLocalName()))
.findFirst()
.orElse(null);
if (define != null) {
var list =
children(define).stream()
.filter(e -> xml.named(e, "BindingList"))
.findFirst()
.orElse(null);
if (list != null) {
var container =
children(list).stream()
.filter(e -> e.getTagName().equals("elements"))
.findFirst()
.orElse(list);
return xml.source.apply(List.of(xml.append(container, body, "")));
}
return xml.source.apply(
List.of(
xml.append(
define,
"<de.neitzel.fx.view.BindingList>" + body + "</de.neitzel.fx.view.BindingList>",
"")));
}
String p = fxPrefix(root);
String wrapped =
"<"
+ p
+ ":define xmlns:"
+ p
+ "=\""
+ FX
+ "\"><de.neitzel.fx.view.BindingList>"
+ body
+ "</de.neitzel.fx.view.BindingList></"
+ p
+ ":define>";
// Definitions precede controls, as in a hand-authored View.
var tag = tag(root);
return xml.source.apply(
List.of(new Replacement(tag.openEnd(), tag.openEnd(), "\n" + wrapped + "\n")));
}
private static List<String> imports(Document d) {
var result = new ArrayList<String>();
for (Node n = d.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction p && p.getTarget().equals("import"))
result.add(p.getData().trim());
return result;
}
private String imports(ReferenceDocument from, String result) {
var ours = imports(xml.document);
var missing = imports(from.xml.document).stream().filter(i -> !ours.contains(i)).toList();
for (String add : missing)
for (String present : ours)
if ((add.endsWith(".*") || present.endsWith(".*"))
|| add.substring(add.lastIndexOf('.') + 1)
.equals(present.substring(present.lastIndexOf('.') + 1)))
throw new IllegalArgumentException(
"Conflicting or wildcard imports need explicit Source resolution before paste");
int at = result.startsWith("<?xml") ? result.indexOf("?>") + 2 : 0;
String newline = xml.text.contains("\r\n") ? "\r\n" : "\n";
String additions =
missing.stream()
.map(i -> "<?import " + i + "?>" + newline)
.collect(java.util.stream.Collectors.joining());
return result.substring(0, at)
+ (at > 0 && !additions.isEmpty() ? newline : "")
+ additions
+ result.substring(at);
}
private static String model(Document d) {
for (Node n = d.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction p && p.getTarget().equals("neitzel-model"))
return p.getData().trim();
return "";
}
private static List<Replacement> difference(String a, String b) {
int start = 0, end = a.length(), last = b.length();
while (start < end && start < last && a.charAt(start) == b.charAt(start)) start++;
while (end > start && last > start && a.charAt(end - 1) == b.charAt(last - 1)) {
end--;
last--;
}
return start == end && start == last
? List.of()
: List.of(new Replacement(start, end, b.substring(start, last)));
}
}
@@ -0,0 +1,153 @@
package de.neitzel.fxdesigner.kit;
import java.util.function.Consumer;
import javafx.scene.control.*;
import javafx.scene.layout.*;
/** Shared, explicitly binding-aware structural actions; clipboard owns immutable text only. */
final class ReferenceEditor extends VBox {
record Intent(String source, String label, ReferenceDocument.Change change, boolean validate) {}
private static ReferenceDocument.Clip clipboard;
private final ListView<ReferenceDocument.Entry> nodes = new ListView<>();
private final TextField name = new TextField();
private final Label status = new Label();
private final String origin;
private final Consumer<Intent> edit;
private ReferenceDocument document;
private String source = "";
private String actionStatus = "";
private boolean loading;
ReferenceEditor(String origin, Consumer<Intent> edit) {
super(5);
this.origin = origin;
this.edit = edit;
nodes.setId("structure-nodes");
nodes.setPrefHeight(95);
name.setId("structure-name");
status.setId("structure-status");
status.setWrapText(true);
nodes
.getSelectionModel()
.selectedItemProperty()
.addListener(
(o, a, b) -> {
if (!loading && b != null) name.setText(b.id());
});
getChildren()
.addAll(
new Label(
"Select a source object or its canvas item. Paste adds children to the selected"
+ " Pane."),
nodes,
new HBox(
6,
new Label("fx:id"),
name,
button(
"Rename identifier",
() ->
apply(
"Rename identifier",
false,
() -> document.rename(selected(), name.getText()))),
button(
"Copy subtree",
() -> {
try {
clipboard = document.copy(selected(), origin);
actionStatus =
"Copied subtree and its associated bindings. Select a destination Pane"
+ " in this or another View.";
status.setText(actionStatus);
} catch (Exception e) {
error(e.getMessage());
}
}),
button(
"Paste subtree",
() ->
apply(
"Paste subtree with bindings",
true,
() -> document.paste(clipboard, selected(), origin))),
button(
"Delete subtree",
() ->
apply(
"Delete subtree with bindings",
true,
() -> document.delete(selected())))),
status);
}
private Button button(String text, Runnable action) {
var b = new Button(text);
b.setOnAction(e -> action.run());
return b;
}
private int selected() {
var n = nodes.getSelectionModel().getSelectedItem();
if (n == null) throw new IllegalArgumentException("Select a source object");
return n.index();
}
private void apply(
String label,
boolean validate,
java.util.concurrent.Callable<ReferenceDocument.Change> action) {
try {
var change = action.call();
if (!change.edits().isEmpty()) {
actionStatus = String.join("\n", change.notes());
status.setText(actionStatus);
edit.accept(new Intent(source, label, change, validate));
}
} catch (Exception e) {
error(e.getMessage());
}
}
void selectIndex(Integer index) {
if (index != null)
nodes.getItems().stream()
.filter(e -> e.index() == index)
.findFirst()
.ifPresent(e -> nodes.getSelectionModel().select(e));
}
void load(String text, boolean writable) {
setDisable(!writable);
if (source.equals(text)) return;
source = text;
loading = true;
try {
var current = nodes.getSelectionModel().getSelectedItem();
document = new ReferenceDocument(text);
nodes.getItems().setAll(document.entries);
var same =
current == null
? null
: document.entries.stream()
.filter(e -> !current.id().isEmpty() && e.id().equals(current.id()))
.findFirst()
.orElse(null);
nodes.getSelectionModel().select(same);
name.setText(same == null ? "" : same.id());
var errors = document.referenceErrors();
status.setText(errors.isEmpty() ? actionStatus : String.join("\n", errors));
} catch (Exception e) {
document = null;
nodes.getItems().clear();
error("Structural actions unavailable: " + e.getMessage());
} finally {
loading = false;
}
}
void error(String message) {
status.setText(message);
}
}
@@ -121,10 +121,10 @@ final class TableDocument {
return List.of(xml.append(parent, node, ""));
}
List<Replacement> removeTable(int index) {
List<Replacement> removeTable(int index) throws Exception {
if (table(index) == xml.document.getDocumentElement())
throw new IllegalArgumentException("A root Table cannot be removed; edit in Source");
return remove(index);
return new ReferenceDocument(xml.text).delete(index).edits();
}
List<Replacement> writeColumn(
@@ -61,6 +61,21 @@ class FxmlProjectionTest {
assertTrue(anonymous.previewText().contains("fx:id=\"__designer_"));
}
@Test
void kitIdentifierRenameUpdatesKnownBindingsInOneSourceTransaction() throws Exception {
String text =
XML.replace(
"<app.Binding control=\"card\" path=\"home\"/>",
"<de.neitzel.fx.view.BindingList><de.neitzel.fx.view.DirectBinding control=\"card\""
+ " path=\"home\" property=\"model\"/></de.neitzel.fx.view.BindingList>");
var projection = new FxmlProjection(text);
String projected = projection.project();
assertEquals(
text.replace("fx:id=\"card\"", "fx:id=\"renamed\"")
.replace("control=\"card\"", "control=\"renamed\""),
projection.restore(projected.replace("fx:id=\"card\"", "fx:id=\"renamed\"")));
}
@Test
void preservesEveryUnchangedCharacterAndUsesAttributeRanges() throws Exception {
String text = XML.replace("caption=\"before\"", "caption = 'a &amp; b'").replace("\n", "\r\n");
@@ -675,6 +675,186 @@ public final class ProjectionHostChecks {
await(() -> fx(() -> source(pane).getText().equals(after)));
}
private static void structureSelect(DesignerPane pane, String id) throws Exception {
fx(
() -> {
var list = (ListView<Object>) pane.lookup("#structure-nodes");
if (id.isEmpty()) list.getSelectionModel().select(0);
else
list.getSelectionModel()
.select(
list.getItems().stream()
.filter(e -> e.toString().startsWith(id + " :"))
.findFirst()
.orElseThrow());
return null;
});
}
private static void structureEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
String base =
full.replace(
"<VBox fx:id=\"visits\"/>",
"<VBox fx:id=\"group\"><VBox fx:id=\"visits\"/><javafx.scene.control.Label"
+ " fx:id=\"summary\" text=\"untouched\"/></VBox>")
.replace(
"</BindingList>",
"<de.neitzel.fx.view.ComputedBinding control=\"summary\" property=\"text\""
+ " method=\"#greeting\"/></BindingList>");
set(document, base);
await(() -> fx(() -> source(pane).getText().equals(base)));
bindingTab(pane, "Structure");
structureSelect(pane, "summary");
fx(
() -> {
((TextField) pane.lookup("#structure-name")).setText("renamedSummary");
return null;
});
click(pane, "Rename identifier");
String renamed =
base.replace("fx:id=\"summary\"", "fx:id=\"renamedSummary\"")
.replace("control=\"summary\"", "control=\"renamedSummary\"");
await(() -> documentText(document).equals(renamed));
repeatHistory(pane, document, base, renamed);
structureSelect(pane, "visits");
fx(
() -> {
((TextField) pane.lookup("#structure-name")).setText("visitList");
return null;
});
click(pane, "Rename identifier");
String repeatRenamed =
renamed
.replace("fx:id=\"visits\"", "fx:id=\"visitList\"")
.replace("pane=\"visits\"", "pane=\"visitList\"");
await(() -> documentText(document).equals(repeatRenamed));
repeatHistory(pane, document, renamed, repeatRenamed);
structureSelect(pane, "group");
click(pane, "Copy subtree");
check(documentText(document).equals(repeatRenamed), "Copy mutated source");
structureSelect(pane, "");
click(pane, "Paste subtree");
await(() -> !documentText(document).equals(repeatRenamed));
String pasted = documentText(document);
check(
pasted.contains("fx:id=\"groupCopy\"")
&& pasted.contains("pane=\"visitListCopy\"")
&& pasted.contains("control=\"renamedSummaryCopy\""),
"Subtree/binding IDs not copied together");
repeatHistory(pane, document, repeatRenamed, pasted);
structureSelect(pane, "group");
click(pane, "Delete subtree");
await(() -> !documentText(document).equals(pasted));
String deleted = documentText(document);
check(
!deleted.contains("pane=\"visitList\"")
&& !deleted.contains("control=\"renamedSummary\"")
&& deleted.contains("pane=\"visitListCopy\""),
"Delete did not isolate descendant target bindings");
repeatHistory(pane, document, pasted, deleted);
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(deleted),
"Structural save changed source");
click(pane, "Interactive preview");
await(
() ->
fx(
() ->
pane.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& "Interactive preview running from captured drafts"
.equals(l.getText()))));
click(pane, "Stop preview");
await(() -> pane.interactivePid() < 0);
structureSelect(pane, "rankInput");
click(pane, "Copy subtree");
var file = java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document());
var root = file.getParent();
while (root != null
&& !java.nio.file.Files.isRegularFile(root.resolve(".neitzel-fx-designer/host.properties")))
root = root.getParent();
check(root != null, "Fixture host configuration absent");
var launch =
de.neitzel.fxdesigner.workflow.HostLaunch.load(
root.resolve(".neitzel-fx-designer/host.properties"));
var destination = file.resolveSibling("AddressForm.fxml");
try (var otherDocument =
pane.kit().host().documents().open(destination.toUri()).toCompletableFuture().get()) {
String original = documentText(otherDocument);
var other = fx(() -> new DesignerPane(pane.kit().host(), launch, destination));
var stage =
fx(
() -> {
var window = new javafx.stage.Stage();
window.setScene(new javafx.scene.Scene(other, 1200, 850));
window.show();
return window;
});
try {
await(() -> fx(() -> other.kit().editor().getFxomDocument() != null));
bindingTab(other, "Structure");
structureSelect(other, "");
click(other, "Paste subtree");
await(() -> !documentText(otherDocument).equals(original));
String cross = documentText(otherDocument);
check(
cross.contains("rankInputCopy") && cross.contains("path=\"rank\""),
"Cross-View paste lost binding payload");
check(
fx(
() ->
((Label) other.lookup("#structure-status"))
.getText()
.contains("Model declarations differ")),
"Cross-View context mismatch not exposed");
bindingTab(other, "Direct bindings");
await(
() ->
fx(
() ->
((Label) other.lookup("#direct-status"))
.getText()
.contains("Unknown or unresolved Model path: rank")));
repeatHistory(other, otherDocument, original, cross);
click(other, "Render drafts");
await(
() ->
fx(
() ->
other.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& l.getText() != null
&& l.getText().startsWith("Preview blocked:")
&& l.getText().contains("rank"))));
set(otherDocument, original);
await(() -> fx(() -> source(other).getText().equals(original)));
} finally {
fx(
() -> {
other.close();
stage.close();
return null;
});
}
}
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
System.out.println(
"PASS reference-safe structure: rename, subtree copy/delete, single-step exact history,"
+ " save/load, cross-View paste and destination validation");
}
private static void tableField(DesignerPane pane, String field, String value) throws Exception {
fx(
() -> {
@@ -1527,6 +1707,7 @@ public final class ProjectionHostChecks {
repeatBindingEdits(pane, document, full);
tableDiscovery(pane, document, full);
tableEdits(pane, document, full);
structureEdits(pane, document, full);
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
@@ -0,0 +1,183 @@
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 ReferenceDocumentTest {
private static final String XML =
"<?neitzel-model app.Person?>\r\n"
+ "<?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?><?import"
+ " de.neitzel.fx.view.*?><VBox xmlns:f='http://javafx.com/fxml/1'"
+ " f:id='root'><f:define><BindingList><elements><!-- before --><DirectBinding control ="
+ " 'child' property='text' path='child' direction='view' extra='keep'/><ComputedBinding"
+ " control='box' property='visible' method='#visible'/><Repeat pane='box'"
+ " component='app.Row' collection='items'/><DirectBinding property='text' path='name'"
+ " direction='view'><control>child</control></DirectBinding><!-- after"
+ " --></elements></BindingList></f:define><!-- outside --><VBox f:id='box'><Label"
+ " f:id='child' text='literal child'/></VBox><Label f:id='other'"
+ " labelFor='$child'/></VBox>";
private int index(ReferenceDocument d, String id) {
return d.entries.stream().filter(e -> e.id().equals(id)).findFirst().orElseThrow().index();
}
private String apply(String before, ReferenceDocument.Change c) throws Exception {
String result =
new EditBatch(4, "structure", c.edits())
.applyTo(new DocumentSnapshot(URI.create("file:/v.fxml"), 4, before, true, false));
new ReferenceDocument(result);
return result;
}
@Test
void renamesTargetReferencesWithoutTouchingPathsMethodsOrLiteralText() throws Exception {
var d = new ReferenceDocument(XML);
String result = apply(XML, d.rename(index(d, "child"), "renamed"));
assertEquals(
XML.replace("f:id='child'", "f:id='renamed'")
.replace("control = 'child'", "control = 'renamed'")
.replace("<control>child</control>", "<control>renamed</control>")
.replace("labelFor='$child'", "labelFor='$renamed'"),
result);
String box = apply(XML, d.rename(index(d, "box"), "group"));
assertTrue(box.contains("control='group'"));
assertTrue(box.contains("pane='group'"));
assertTrue(box.contains("method='#visible'"));
}
@Test
void nestedTargetRenameRetainsWhitespaceAndComments() throws Exception {
String text =
XML.replace(
"<control>child</control>",
"<control>\r\n<!-- target --> child <!-- retained -->\r\n</control>");
var d = new ReferenceDocument(text);
String result = apply(text, d.rename(index(d, "child"), "newName"));
assertTrue(
result.contains("<control>\r\n<!-- target --> newName <!-- retained -->\r\n</control>"));
}
@Test
void deletesSubtreeAndOnlyItsTargetBindingsLeavingOtherDependenciesVisible() throws Exception {
var d = new ReferenceDocument(XML);
String result = apply(XML, d.delete(index(d, "box")));
assertFalse(result.contains("<DirectBinding"));
assertFalse(result.contains("<ComputedBinding"));
assertFalse(result.contains("<Repeat"));
assertTrue(result.contains("<!-- before --><!-- after -->"));
assertTrue(result.contains("<!-- outside -->"));
assertTrue(result.contains("labelFor='$child'"));
assertEquals(
List.of("Unresolved FXML reference: child"),
new ReferenceDocument(result).referenceErrors());
}
@Test
void copiesWholeSubtreeWithFreshIdsAndExactBindingPayloads() throws Exception {
var d = new ReferenceDocument(XML);
var clip = d.copy(index(d, "box"), "one");
String result = apply(XML, d.paste(clip, index(d, "root"), "one"));
assertTrue(
result.contains(
"<VBox f:id='boxCopy'><Label f:id='childCopy' text='literal child'/></VBox>"));
assertTrue(
result.contains(
"control = 'childCopy' property='text' path='child' direction='view' extra='keep'"));
assertTrue(result.contains("pane='boxCopy'"));
assertTrue(result.contains("<control>childCopy</control>"));
assertTrue(result.contains("control = 'child'"));
assertTrue(result.contains("<!-- outside -->"));
var n = new ReferenceDocument(result);
String twice = apply(result, n.paste(clip, index(n, "root"), "one"));
assertTrue(twice.contains("f:id='childCopy2'"));
}
@Test
void carriesNamespacesImportsAndReportsDestinationContextDifferences() throws Exception {
String source =
XML.replace(
"<?import de.neitzel.fx.view.*?>",
"<?import de.neitzel.fx.view.BindingList?><?import"
+ " de.neitzel.fx.view.DirectBinding?><?import"
+ " de.neitzel.fx.view.ComputedBinding?><?import de.neitzel.fx.view.Repeat?>");
var from = new ReferenceDocument(source);
String target =
"<?neitzel-model app.Other?><?import javafx.scene.layout.VBox?><VBox"
+ " xmlns:fx='http://javafx.com/fxml/1' fx:controller='app.OtherController'/>";
var d = new ReferenceDocument(target);
var change = d.paste(from.copy(index(from, "box"), "source"), 0, "destination");
String result = apply(target, change);
assertTrue(result.contains("<?neitzel-model app.Other?>"));
assertTrue(result.contains("xmlns:f=\"http://javafx.com/fxml/1\""));
assertTrue(result.contains("<?import javafx.scene.control.Label?>"));
assertTrue(change.notes().stream().anyMatch(n -> n.contains("Model declarations differ")));
assertTrue(change.notes().stream().anyMatch(n -> n.contains("Controllers differ")));
assertEquals(
4,
new BindingDocument(result)
.elements.stream()
.filter(e -> e.getTagName().endsWith("Binding") || e.getTagName().equals("Repeat"))
.count());
}
@Test
void updatesSimpleFxmlExpressionsAndReferenceNodesAndRejectsComplexOnes() throws Exception {
String text =
XML.replace("labelFor='$child'", "labelFor='${child.text}'")
.replace(
"</VBox><Label f:id='other'",
"<f:reference source='child'/></VBox><Label f:id='other'");
var d = new ReferenceDocument(text);
String result = apply(text, d.rename(index(d, "child"), "x"));
assertTrue(result.contains("${x.text}"));
assertTrue(result.contains("source='x'"));
var complex =
new ReferenceDocument(
XML.replace("labelFor='$child'", "text='${child.text + other.text}'"));
assertThrows(
IllegalArgumentException.class, () -> complex.rename(index(complex, "child"), "x"));
}
@Test
void rejectsDuplicateIdsCollisionsRootsUnknownBindingsAndUnsafePaste() throws Exception {
var d = new ReferenceDocument(XML);
assertThrows(IllegalArgumentException.class, () -> d.rename(index(d, "child"), "box"));
assertThrows(IllegalArgumentException.class, () -> d.rename(index(d, "child"), "bad id"));
assertThrows(IllegalArgumentException.class, () -> d.delete(0));
assertThrows(IllegalArgumentException.class, () -> d.copy(0, "one"));
assertThrows(
IllegalArgumentException.class,
() -> new ReferenceDocument(XML.replace("f:id='other'", "f:id='child'")));
var unknown =
new ReferenceDocument(
XML.replace("<!-- before -->", "<app.Unknown control='child'/><!-- before -->"));
assertThrows(
IllegalArgumentException.class, () -> unknown.rename(index(unknown, "child"), "x"));
assertThrows(
IllegalArgumentException.class,
() -> d.paste(d.copy(index(d, "box"), "one"), index(d, "other"), "one"));
}
@Test
void copyRemapsInternalReferencesAndDoesNotRetargetExternalOnes() throws Exception {
String text = XML.replace("text='literal child'", "text='literal child' labelFor='$box'");
var d = new ReferenceDocument(text);
String result = apply(text, d.paste(d.copy(index(d, "box"), "one"), 0, "one"));
assertTrue(result.contains("labelFor='$boxCopy'"));
assertTrue(result.contains("labelFor='$child'"));
}
@Test
void assignsIdToAnonymousObjectWithoutWritingProjectionIdentity() throws Exception {
var d =
new ReferenceDocument(
"<javafx.scene.layout.VBox><javafx.scene.control.Label/></javafx.scene.layout.VBox>");
String result = apply(d.xml.text, d.rename(1, "label"));
assertTrue(result.contains("fx:id=\"label\""));
assertFalse(result.contains("__designer_"));
}
}
@@ -75,6 +75,22 @@ class TableDocumentTest {
}
}
@Test
void removingTableAlsoRemovesItsTargetBindings() throws Exception {
String declaration = "<DirectBinding control='t' property='visible' path='enabled'/>";
String text =
START.replace(
"<!-- keep -->",
"<f:define><BindingList><!-- binding -->"
+ declaration
+ "</BindingList></f:define><!-- keep -->");
var d = new TableDocument(text);
String result = apply(text, d.removeTable(d.tables.getFirst().index()));
assertFalse(result.contains(declaration));
assertTrue(result.contains("<!-- binding -->"));
assertTrue(result.contains("<Label text='untouched'/>"));
}
@Test
void preservesNestedDeclarationsAndRejectsAmbiguousContainers() throws Exception {
String text =