From 910a8b0e85f9f491f17707dc69e403b24dd6b246 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Fri, 13 Nov 2026 16:13:03 +0100 Subject: [PATCH] Add reference-safe FXML rename, subtree copy and delete --- README.md | 2 +- .../neitzel/fxdesigner/kit/DesignerPane.java | 32 +- .../fxdesigner/kit/FxmlProjection.java | 44 +- .../fxdesigner/kit/ReferenceDocument.java | 527 ++++++++++++++++++ .../fxdesigner/kit/ReferenceEditor.java | 153 +++++ .../neitzel/fxdesigner/kit/TableDocument.java | 4 +- .../fxdesigner/kit/FxmlProjectionTest.java | 15 + .../fxdesigner/kit/ProjectionHostChecks.java | 181 ++++++ .../fxdesigner/kit/ReferenceDocumentTest.java | 183 ++++++ .../fxdesigner/kit/TableDocumentTest.java | 16 + docs/reference-safe-structure.md | 31 ++ docs/repository-bootstrap-plan.md | 4 + docs/source-preserving-projection.md | 6 +- 13 files changed, 1191 insertions(+), 7 deletions(-) create mode 100644 designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/ReferenceDocument.java create mode 100644 designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/ReferenceEditor.java create mode 100644 designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ReferenceDocumentTest.java create mode 100644 docs/reference-safe-structure.md diff --git a/README.md b/README.md index 27766d5..cf4a075 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with minimal standalone and The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits. -The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). The shared [Table/Column inspector](docs/table-column-inspector.md) adds source-preserving Table configuration, ordered Column authoring and explicit Row navigation, now with [Table-specific compatibility and declared Row alignment discovery](docs/table-compatibility.md). Broader discovery, generation, structural editing and product packaging remain later work. +The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). The shared [Table/Column inspector](docs/table-column-inspector.md) adds source-preserving Table configuration, ordered Column authoring and explicit Row navigation, now with [Table-specific compatibility and declared Row alignment discovery](docs/table-compatibility.md). The shared [reference-safe Structure editor](docs/reference-safe-structure.md) now renames identifiers, copies subtrees with fresh identifiers and deletes associated bindings through source-preserving host Undo/Redo, including destination validation for cross-View paste. Broader discovery, generation and product packaging remain later work. Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats and validation rules. diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java index e0f5c0a..283a721 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java @@ -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 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) diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/FxmlProjection.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/FxmlProjection.java index a907d17..46b33e5 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/FxmlProjection.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/FxmlProjection.java @@ -92,6 +92,10 @@ public final class FxmlProjection { return w.toString(); } + Integer sourceIndex(String id) { + return sourceNodes.get(id); + } + public Map components() { return Map.copyOf(components); } @@ -278,6 +282,14 @@ public final class FxmlProjection { return result; } + private static List objectElements(Document document) { + var result = new ArrayList(); + 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(); + // 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(); for (Element element : List.of(before.get(id), after.get(id))) diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/ReferenceDocument.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/ReferenceDocument.java new file mode 100644 index 0000000..ed35cf6 --- /dev/null +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/ReferenceDocument.java @@ -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 edits, List notes) {} + + private record Target(Element binding, Element element, String attribute, String value) {} + + final BindingDocument xml; + final List entries = new ArrayList<>(); + private final Map ids = new LinkedHashMap<>(); + private final List 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 children(Element e) { + var result = new ArrayList(); + 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 attributes(Element e, Map 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("child