Add reference-safe FXML rename, subtree copy and delete
This commit is contained in:
@@ -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 & 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 =
|
||||
|
||||
Reference in New Issue
Block a user