Preserve FXML source ranges and extend shared Component projection

This commit is contained in:
2026-11-12 11:52:43 +01:00
parent 7db7218a6e
commit dfa2dd1508
19 changed files with 1145 additions and 49 deletions
@@ -28,10 +28,83 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private final TextArea source = new TextArea(), output = new TextArea();
private final ComboBox<DesignerWorkflow.Example> examples = new ComboBox<>();
private final SplitPane design;
private final ContentPanelController content;
public ContentPanelController content() {
return content;
}
private final VBox componentInspector = new VBox(4);
private void componentSelection() {
componentInspector.getChildren().clear();
if (projection == null) return;
var hit = kit.editor().getSelection().getHitItem();
if (hit == null) return;
String id = hit.getFxId();
if (id == null || !projection.components().containsKey(id)) return;
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
componentInspector
.getChildren()
.add(new Label("Declared FXML properties (validated by target runtime)"));
projection
.componentAttributes(id)
.forEach(
(name, value) -> {
var field = new TextField(value);
field.setId("component-property-" + name);
field.setOnAction(
e -> {
try {
submit(
projection.componentAttribute(id, name, field.getText()),
"Edit Component " + name);
} catch (Exception error) {
report(error);
}
});
componentInspector.getChildren().add(new HBox(4, new Label(name), field));
});
}
private final Timeline poll;
private final PauseTransition typing = new PauseTransition(Duration.millis(250)),
resize = new PauseTransition(Duration.millis(350));
private String submittedSource;
private List<EditBatch.Replacement> pendingVisual;
private String pendingBase;
private static String apply(String text, List<EditBatch.Replacement> edits) {
return new EditBatch(0, "projection", edits)
.applyTo(
new DocumentSnapshot(java.net.URI.create("file:/projection"), 0, text, true, false));
}
private static List<EditBatch.Replacement> difference(String before, String after) {
int start = 0, end = before.length(), last = after.length();
while (start < end && start < last && before.charAt(start) == after.charAt(start)) start++;
while (end > start && last > start && before.charAt(end - 1) == after.charAt(last - 1)) {
end--;
last--;
}
return start == end && start == last
? List.of()
: List.of(new EditBatch.Replacement(start, end, after.substring(start, last)));
}
private static int mapped(int offset, List<EditBatch.Replacement> edits) {
int delta = 0;
var sorted = new ArrayList<>(edits);
sorted.sort(Comparator.comparingInt(EditBatch.Replacement::start));
for (var edit : sorted) {
if (offset < edit.start()) break;
if (offset <= edit.end())
return edit.start() + delta + Math.min(offset - edit.start(), edit.text().length());
delta += edit.text().length() - (edit.end() - edit.start());
}
return offset + delta;
}
private DocumentSession document;
private DocumentSnapshot draft;
private FxmlProjection projection;
@@ -49,12 +122,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
this.file = file;
Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
kit = new KitView(host);
content = new ContentPanelController(kit.editor());
design =
new SplitPane(
new HierarchyPanelController(kit.editor()).getPanelRoot(),
new ContentPanelController(kit.editor()).getPanelRoot(),
content.getPanelRoot(),
new InspectorPanelController(kit.editor()).getPanelRoot());
design.setDividerPositions(.18, .72);
kit.editor().getSelection().revisionProperty().addListener((o, a, b) -> componentSelection());
workflow =
new DesignerWorkflow(
host,
@@ -102,21 +177,35 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
return null;
}
});
var tabs = new TabPane();
var split = new SplitPane(source, design);
split.setDividerPositions(.3);
tabs.getTabs().add(new Tab("Source / Design", split));
tabs.getTabs().getFirst().setClosable(false);
var modes =
new ToolBar(
button("Source", () -> split.getItems().setAll(source)),
button("Design", () -> split.getItems().setAll(design)),
button(
"Split",
() -> {
split.getItems().setAll(source, design);
split.setDividerPositions(.3);
}));
var center = new BorderPane(split);
center.setTop(modes);
var properties = new ScrollPane(componentInspector);
properties.setFitToWidth(true);
properties.setPrefViewportHeight(100);
center.setBottom(properties);
output.setEditable(false);
output.setPrefRowCount(5);
setTop(bar);
setCenter(tabs);
setCenter(center);
setBottom(new VBox(status, output));
source
.textProperty()
.addListener(
(o, a, b) -> {
if (!loading) {
if (pending && submittedSource == null) submittedSource = pendingBase;
commitSource();
workflow.cancel();
renderSequence.incrementAndGet();
@@ -211,6 +300,31 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
return;
}
if (submittedSource != null && !source.getText().equals(submittedSource)) {
// Source typing can arrive before an asynchronous visual command completes.
// Rebase disjoint visual ranges; an overlapping later source edit wins explicitly
// in the next host command, whose Undo still restores the visual command.
if (submittedSource.equals(pendingBase) && pendingVisual != null) {
var typed = difference(pendingBase, source.getText());
var rebased = new ArrayList<EditBatch.Replacement>();
for (var visual : pendingVisual) {
if (typed.stream()
.noneMatch(t -> t.start() <= visual.end() && visual.start() <= t.end()))
rebased.add(
new EditBatch.Replacement(
mapped(visual.start(), typed),
mapped(visual.end(), typed),
visual.text()));
}
int anchor = source.getAnchor(), caret = source.getCaretPosition();
loading = true;
try {
rebased.sort(Comparator.comparingInt(EditBatch.Replacement::start).reversed());
for (var edit : rebased) source.replaceText(edit.start(), edit.end(), edit.text());
source.selectRange(mapped(anchor, rebased), mapped(caret, rebased));
} finally {
loading = false;
}
}
draft = value;
submittedSource = null;
commitSource();
@@ -227,8 +341,23 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
renderSequence.incrementAndGet();
workflow.cancel();
try {
var hit = kit.editor().getSelection().getHitItem();
String selectedId = hit == null ? null : hit.getFxId();
draft = value;
if (!source.getText().equals(value.text())) source.setText(value.text());
if (!source.getText().equals(value.text())) {
int anchor = source.getAnchor(), caret = source.getCaretPosition();
var edits =
pendingVisual != null
&& source.getText().equals(pendingBase)
&& apply(pendingBase, pendingVisual).equals(value.text())
? pendingVisual
: difference(source.getText(), value.text());
var descending = new ArrayList<>(edits);
descending.sort(Comparator.comparingInt(EditBatch.Replacement::start).reversed());
for (var edit : descending) source.replaceText(edit.start(), edit.end(), edit.text());
source.selectRange(mapped(anchor, edits), mapped(caret, edits));
}
pendingVisual = null;
source.setEditable(value.writable());
// End an inspector editing session before replacing FXOM (also after native undo).
if (getScene() != null
@@ -238,6 +367,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
String projected = projection.project();
kit.editor().setFxmlTextAndLocation(projected, file.toUri().toURL());
design.setDisable(!value.writable());
componentInspector.setDisable(!value.writable());
kit.editor()
.getFxomDocument()
.sceneGraphRevisionProperty()
@@ -261,6 +391,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
});
}
}
if (selectedId != null) {
var selected = new FXOMFxIdIndex(kit.editor().getFxomDocument()).lookup(selectedId);
if (selected != null) kit.editor().getSelection().select(selected);
}
componentSelection();
status.setText(
"Context "
+ launch.context()
@@ -269,6 +404,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
+ (value.dirty() ? " · unsaved" : ""));
} catch (Throwable error) {
design.setDisable(true);
componentInspector.setDisable(true);
projection = null;
report(error);
} finally {
loading = false;
@@ -285,14 +422,15 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private void replace(String text, String name) {
if (draft == null || pending) return;
submit(difference(draft.text(), text), name);
}
private void submit(List<EditBatch.Replacement> edits, String name) {
if (draft == null || pending || edits.isEmpty()) return;
pending = true;
document
.apply(
new EditBatch(
draft.revision(),
name,
List.of(new EditBatch.Replacement(0, draft.text().length(), text))))
.whenComplete(this::updated);
pendingVisual = edits;
pendingBase = draft.text();
document.apply(new EditBatch(draft.revision(), name, edits)).whenComplete(this::updated);
}
private void commitSource() {
@@ -305,8 +443,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private void canvasChanged() {
if (loading || pending || closed) return;
try {
replace(
projection.restore(kit.editor().getFxomDocument().getFxmlText(false)),
submit(
projection.edits(kit.editor().getFxomDocument().getFxmlText(false)),
"Component layout / property edit");
} catch (Throwable error) {
report(error);
@@ -439,6 +577,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
&& Path.of(d.document())
.startsWith(launch.configuration().getParent()))
.forEach(d -> drafts.put(d.document(), d));
drafts.put(
draft.document(),
new DocumentSnapshot(
draft.document(),
draft.revision(),
projection.previewText(),
draft.writable(),
draft.dirty()));
renderComponents(List.copyOf(drafts.values()), selected.id(), ticket);
}));
});
@@ -36,13 +36,36 @@ public final class FxmlProjection {
"GridPane.columnIndex",
"GridPane.rowIndex",
"GridPane.hgrow",
"GridPane.vgrow");
"GridPane.vgrow",
"GridPane.columnSpan",
"GridPane.rowSpan",
"AnchorPane.topAnchor",
"AnchorPane.bottomAnchor",
"AnchorPane.leftAnchor",
"AnchorPane.rightAnchor",
"HBox.hgrow",
"VBox.vgrow",
"StackPane.alignment",
"HBox.alignment",
"VBox.alignment",
"translateX",
"translateY",
"rotate",
"scaleX",
"scaleY",
"style",
"wrapText");
private final Document original;
private final XmlSource source;
private final Map<String, Integer> sourceNodes = new LinkedHashMap<>();
private final Map<String, Element> sourceElements = new LinkedHashMap<>();
private final Set<String> synthetic = new HashSet<>();
private Document baseline;
private final Map<String, String> components = new LinkedHashMap<>();
public FxmlProjection(String text) throws Exception {
original = parse(text);
source = new XmlSource(text);
}
static Document parse(String text) throws Exception {
@@ -66,7 +89,46 @@ public final class FxmlProjection {
}
public String project() throws Exception {
components.clear();
sourceNodes.clear();
sourceElements.clear();
synthetic.clear();
var d = (Document) original.cloneNode(true);
var all = d.getElementsByTagName("*");
var originals = original.getElementsByTagName("*");
var reserved = new HashSet<String>();
for (int i = 0; i < all.getLength(); i++) {
String id = ((Element) all.item(i)).getAttributeNS(FX, "id");
if (!id.isBlank() && !reserved.add(id)) throw new IOException("Duplicate fx:id: " + id);
}
for (int i = 0; i < all.getLength(); i++) {
var element = (Element) all.item(i);
if (!isObject(element)
&& !(i == 0
&& FX.equals(element.getNamespaceURI())
&& "root".equals(element.getLocalName()))) continue;
String id = element.getAttributeNS(FX, "id");
if (id.isBlank()) {
id = "__designer_" + i;
while (reserved.contains(id)) id += "_";
reserved.add(id);
synthetic.add(id);
element.setAttributeNS(FX, "fx:id", id);
}
element.removeAttributeNS(FX, "id");
element.setAttributeNS(FX, "fx:id", id);
sourceNodes.put(id, i);
sourceElements.put(id, (Element) originals.item(i));
}
if (!d.getDocumentElement().hasAttribute("xmlns:fx"))
d.getDocumentElement().setAttribute("xmlns:fx", FX);
var root = d.getDocumentElement();
if (FX.equals(root.getNamespaceURI()) && "root".equals(root.getLocalName())) {
String type = root.getAttribute("type");
if (type.isBlank()) throw new IOException("fx:root requires a type");
d.renameNode(root, null, type);
root.removeAttribute("type");
}
var imports = new HashMap<String, String>();
var remove = new ArrayList<Node>();
for (Node n = d.getFirstChild(); n != null; n = n.getNextSibling())
@@ -94,18 +156,27 @@ public final class FxmlProjection {
String tag = e.getTagName();
if (FX.equals(e.getNamespaceURI()))
throw new IOException("This minimal projection does not support " + tag);
if (imports.containsKey(tag)) {
String componentType = imports.get(tag);
if (componentType == null
&& tag.contains(".")
&& Character.isUpperCase(tag.charAt(tag.lastIndexOf('.') + 1))
&& !tag.startsWith("javafx.")) componentType = tag;
if (componentType != null) {
String id = e.getAttributeNS(FX, "id");
if (id.isBlank()) throw new IOException("Component projection requires fx:id");
components.put(id, imports.get(tag));
if (e.hasChildNodes())
throw new IOException("Inline Component content is outside this projection");
components.put(id, componentType);
// Component internals belong to the target runtime and their own source document.
while (e.hasChildNodes()) e.removeChild(e.getFirstChild());
d.renameNode(e, null, "SnapshotRegion");
var attrs = e.getAttributes();
for (int a = attrs.getLength() - 1; a >= 0; a--) {
var attr = (Attr) attrs.item(a);
if (!EDITABLE.contains(attr.getName()) && !attr.getName().equals("fx:id"))
e.removeAttributeNode(attr);
if ((!EDITABLE.contains(attr.getName())
|| attr.getName().equals("text")
|| attr.getName().equals("wrapText")
|| attr.getName().equals("spacing")
|| attr.getName().equals("hgap")
|| attr.getName().equals("vgap"))
&& !attr.getName().equals("fx:id")) e.removeAttributeNode(attr);
}
} else if (Character.isUpperCase(tag.charAt(0))
&& tag.contains(".")
@@ -126,7 +197,7 @@ public final class FxmlProjection {
private static Map<String, Element> indexed(Document d) throws IOException {
var result = new LinkedHashMap<String, Element>();
result.put("@root", d.getDocumentElement());
var nodes = d.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
@@ -144,9 +215,14 @@ public final class FxmlProjection {
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
String tag = e.getTagName();
if (Character.isUpperCase(tag.charAt(0))) {
if (isObject(e)) {
Node parent = e.getParentNode();
while (parent != null && !objects.containsKey(parent)) parent = parent.getParentNode();
var propertyPath = new ArrayList<String>();
while (parent != null && !objects.containsKey(parent)) {
if (parent instanceof Element wrapper && !wrapper.getTagName().equals("children"))
propertyPath.add(wrapper.getTagName());
parent = parent.getParentNode();
}
var fixed = new TreeMap<String, String>();
if (e != document.getDocumentElement() && e.getAttributeNS(FX, "id").isBlank())
for (int a = 0; a < e.getAttributes().getLength(); a++) {
@@ -155,25 +231,39 @@ public final class FxmlProjection {
fixed.put(attr.getNodeName(), attr.getNodeValue());
}
result.add(
tag
tag.substring(tag.lastIndexOf('.') + 1)
+ ":"
+ e.getAttributeNS(FX, "id")
+ ":"
+ objects.getOrDefault(parent, -1)
+ ":"
+ fixed);
+ fixed
+ ":"
+ propertyPath);
objects.put(e, objects.size());
} else if (!e.getTextContent().isBlank() && e.getElementsByTagName("*").getLength() == 0) {
result.add("property:" + tag + ":" + e.getTextContent());
}
}
return result;
}
private static boolean isObject(Element e) {
String tag = e.getTagName();
return !FX.equals(e.getNamespaceURI())
&& Character.isUpperCase(tag.charAt(tag.lastIndexOf('.') + 1));
}
public String restore(String projected) throws Exception {
return source.apply(edits(projected));
}
public java.util.List<de.neitzel.fxdesigner.contracts.EditBatch.Replacement> edits(
String projected) throws Exception {
var changed = parse(projected);
var before = indexed(baseline);
var after = indexed(changed);
var output = (Document) original.cloneNode(true);
var targets = indexed(output);
var result = new ArrayList<de.neitzel.fxdesigner.contracts.EditBatch.Replacement>();
if (!before.keySet().equals(after.keySet()) || !structure(baseline).equals(structure(changed)))
throw new IOException("Structural editing is outside this minimal projection");
for (String id : before.keySet()) {
@@ -187,18 +277,72 @@ public final class FxmlProjection {
&& !before.get(id).getAttribute(attr).equals(after.get(id).getAttribute(attr)))
throw new IOException("Edit " + attr + " in source; this projection does not support it");
}
for (String id : before.keySet())
var requiredImports = new TreeSet<String>();
for (String id : before.keySet()) {
var values = new HashMap<String, String>();
for (String attr : EDITABLE) {
String old = before.get(id).getAttribute(attr), value = after.get(id).getAttribute(attr);
if (!old.equals(value)) {
var target = targets.get(id);
if (!old.equals(value)
|| before.get(id).hasAttribute(attr) != after.get(id).hasAttribute(attr)) {
var target = sourceElements.get(id);
String originalValue = target.getAttribute(attr);
if (originalValue.startsWith("$") || originalValue.startsWith("#"))
throw new IOException("Bound attributes must be edited in source");
if (value.isEmpty()) target.removeAttribute(attr);
else target.setAttribute(attr, value);
values.put(attr, after.get(id).hasAttribute(attr) ? value : null);
if (after.get(id).hasAttribute(attr) && attr.contains(".")) {
String owner = "javafx.scene.layout." + attr.substring(0, attr.indexOf('.'));
boolean imported = false;
for (Node n = original.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction pi
&& pi.getTarget().equals("import")
&& (pi.getData().trim().equals(owner)
|| pi.getData().trim().equals("javafx.scene.layout.*"))) imported = true;
if (!imported) requiredImports.add(owner);
}
}
}
return xml(output);
result.addAll(source.attributes(sourceNodes.get(id), values));
}
if (!requiredImports.isEmpty()) {
int at = source.text.matches("(?s)^<\\?xml\\s.*") ? source.text.indexOf("?>") + 2 : 0;
String newline = source.text.contains("\r\n") ? "\r\n" : "\n";
String declarations =
(at == 0 ? "" : newline)
+ requiredImports.stream()
.map(type -> "<?import " + type + "?>" + newline)
.collect(java.util.stream.Collectors.joining());
result.add(new de.neitzel.fxdesigner.contracts.EditBatch.Replacement(at, at, declarations));
}
return List.copyOf(result);
}
public Map<String, String> componentAttributes(String id) {
var values = new TreeMap<String, String>();
if (!components.containsKey(id)) return values;
var attrs = sourceElements.get(id).getAttributes();
for (int i = 0; i < attrs.getLength(); i++) {
var attr = attrs.item(i);
if (!attr.getNodeName().contains(":") && !attr.getNodeName().startsWith("on"))
values.put(attr.getNodeName(), attr.getNodeValue());
}
return values;
}
public List<de.neitzel.fxdesigner.contracts.EditBatch.Replacement> componentAttribute(
String id, String name, String value) throws IOException {
if (!componentAttributes(id).containsKey(name))
throw new IOException("Unknown declared Component property");
if (sourceElements.get(id).getAttribute(name).equals(value)) return List.of();
return source.attributes(sourceNodes.get(id), Collections.singletonMap(name, value));
}
public String previewText() {
var edits = new ArrayList<de.neitzel.fxdesigner.contracts.EditBatch.Replacement>();
for (String id : synthetic)
if (components.containsKey(id))
edits.addAll(source.attributes(sourceNodes.get(id), Map.of("fx:id", id)));
if (!edits.isEmpty() && !original.getDocumentElement().hasAttribute("xmlns:fx"))
edits.addAll(source.attributes(0, Map.of("xmlns:fx", FX)));
return source.apply(edits);
}
}
@@ -0,0 +1,105 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import java.io.IOException;
import java.util.*;
import java.util.regex.*;
/** Lexical ranges for an already securely parsed XML document; never serializes untouched XML. */
final class XmlSource {
record Attribute(int start, int end, int valueStart, int valueEnd, char quote) {}
record Tag(int insert, Map<String, Attribute> attributes) {}
private static final Pattern ATTRIBUTE =
Pattern.compile("([^\\s=<>/]+)\\s*=\\s*(['\"])(.*?)\\2", Pattern.DOTALL);
final String text;
final List<Tag> tags = new ArrayList<>();
XmlSource(String text) throws IOException {
this.text = text;
int i = 0;
while ((i = text.indexOf('<', i)) >= 0) {
String end =
text.startsWith("<!--", i)
? "-->"
: text.startsWith("<![CDATA[", i) ? "]]>" : text.startsWith("<?", i) ? "?>" : null;
if (end != null) {
int close = text.indexOf(end, i);
if (close < 0) throw new IOException("Incomplete XML");
i = close + end.length();
continue;
}
int start = i++, quote = 0;
while (i < text.length()) {
char c = text.charAt(i);
if (quote != 0) {
if (c == quote) quote = 0;
} else if (c == '\'' || c == '"') quote = c;
else if (c == '>') break;
i++;
}
if (i == text.length()) throw new IOException("Incomplete XML tag");
if (text.charAt(start + 1) != '/') {
var attrs = new LinkedHashMap<String, Attribute>();
var matcher = ATTRIBUTE.matcher(text);
matcher.region(start + 1, i);
while (matcher.find())
attrs.put(
matcher.group(1),
new Attribute(
matcher.start(),
matcher.end(),
matcher.start(3),
matcher.end(3),
matcher.group(2).charAt(0)));
tags.add(new Tag(text.charAt(i - 1) == '/' ? i - 1 : i, attrs));
}
i++;
}
}
List<Replacement> attributes(int index, Map<String, String> values) {
var tag = tags.get(index);
var result = new ArrayList<Replacement>();
var added = new StringBuilder();
new TreeMap<>(values)
.forEach(
(name, value) -> {
var attr = tag.attributes().get(name);
if (attr == null) {
if (value != null)
added
.append(' ')
.append(name)
.append("=\"")
.append(escape(value, '"'))
.append('"');
} else if (value == null) result.add(new Replacement(attr.start(), attr.end(), ""));
else
result.add(
new Replacement(
attr.valueStart(), attr.valueEnd(), escape(value, attr.quote())));
});
if (!added.isEmpty()) result.add(new Replacement(tag.insert(), tag.insert(), added.toString()));
return result;
}
static String escape(String value, char quote) {
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(quote == '"' ? "\"" : "'", quote == '"' ? "&quot;" : "&apos;")
.replace("\r", "&#13;")
.replace("\n", "&#10;")
.replace("\t", "&#9;");
}
String apply(List<Replacement> edits) {
var sorted = new ArrayList<>(edits);
sorted.sort(Comparator.comparingInt(Replacement::start).reversed());
var out = new StringBuilder(text);
for (var edit : sorted) out.replace(edit.start(), edit.end(), edit.text());
return out.toString();
}
}