Preserve FXML source ranges and extend shared Component projection
This commit is contained in:
@@ -29,6 +29,18 @@
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals><goal>test-jar</goal></goals>
|
||||
<configuration>
|
||||
<includes><include>**/ProjectionHostChecks*.class</include></includes>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(quote == '"' ? "\"" : "'", quote == '"' ? """ : "'")
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
.replace("\t", "	");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -49,13 +49,114 @@ class FxmlProjectionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStructuralEditsAndUnnamedComponents() throws Exception {
|
||||
void rejectsStructuralEditsAndSupportsUnnamedComponents() throws Exception {
|
||||
var projection = new FxmlProjection(XML);
|
||||
String projected = projection.project();
|
||||
assertThrows(
|
||||
Exception.class,
|
||||
() -> projection.restore(projected.replace("fx:id=\"card\"", "fx:id=\"different\"")));
|
||||
var anonymous = new FxmlProjection(XML.replace("fx:id=\"card\"", ""));
|
||||
String view = anonymous.project();
|
||||
assertEquals(XML.replace("fx:id=\"card\"", ""), anonymous.restore(view));
|
||||
assertTrue(anonymous.previewText().contains("fx:id=\"__designer_"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesEveryUnchangedCharacterAndUsesAttributeRanges() throws Exception {
|
||||
String text = XML.replace("caption=\"before\"", "caption = 'a & b'").replace("\n", "\r\n");
|
||||
var p = new FxmlProjection(text);
|
||||
String view = p.project();
|
||||
assertEquals(text, p.restore(view));
|
||||
assertTrue(p.componentAttribute("card", "caption", "a & b").isEmpty());
|
||||
var edits = p.edits(view.replace("caption=\"a & b\"", "caption=\"after\""));
|
||||
assertEquals(1, edits.size());
|
||||
assertEquals("a & b", text.substring(edits.getFirst().start(), edits.getFirst().end()));
|
||||
assertEquals(
|
||||
text.replace("a & b", "after"),
|
||||
p.restore(view.replace("caption=\"a & b\"", "caption=\"after\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectsFullyQualifiedNestedAndAnonymousComponents() throws Exception {
|
||||
String text =
|
||||
"<?import javafx.scene.layout.VBox?><VBox"
|
||||
+ " xmlns:fx=\"http://javafx.com/fxml/1\"><VBox><app.Card caption='one'/><app.Card"
|
||||
+ " caption='two'><opaque value='retained'/></app.Card></VBox></VBox>";
|
||||
var p = new FxmlProjection(text);
|
||||
String view = p.project();
|
||||
assertEquals(2, p.components().size());
|
||||
assertEquals(
|
||||
text.replace("caption='two'", "caption='changed'"),
|
||||
p.restore(view.replace("caption=\"two\"", "caption=\"changed\"")));
|
||||
assertTrue(p.previewText().contains("<opaque value='retained'/ >".replace("/ >", "/>")));
|
||||
assertEquals(text, p.restore(view));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fxRootAndUnnamedStandardControlsKeepOriginalDeclarations() throws Exception {
|
||||
String text =
|
||||
"<?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?><fx:root"
|
||||
+ " type='VBox' xmlns:fx='http://javafx.com/fxml/1'"
|
||||
+ " xmlns='http://javafx.com/javafx/21'><Label text='before'/></fx:root>";
|
||||
var p = new FxmlProjection(text);
|
||||
assertEquals(
|
||||
text.replace("text='before'", "text='after'"),
|
||||
p.restore(p.project().replace("text=\"before\"", "text=\"after\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourcePropertyEditsEscapeQuotesWithoutTouchingUnknownContent() throws Exception {
|
||||
var p = new FxmlProjection(XML);
|
||||
p.project();
|
||||
var changes = p.componentAttribute("card", "custom", "a\"<&\n");
|
||||
assertEquals(1, changes.size());
|
||||
assertEquals("a"<& ", changes.getFirst().text());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesAlternatePrefixesCommentsCdataAndQuotedDelimiters() throws Exception {
|
||||
String text =
|
||||
"<?import javafx.scene.layout.VBox?><?import app.Card?><VBox"
|
||||
+ " xmlns:f='http://javafx.com/fxml/1'><!-- <Card/> --><f:define><String"
|
||||
+ " f:id='hidden'><![CDATA[<x/>]]></String></f:define><Card f:id='card' caption = 'a"
|
||||
+ " > b' custom='"'/></VBox>";
|
||||
var p = new FxmlProjection(text);
|
||||
String projected = p.project();
|
||||
assertEquals(
|
||||
text.replace("a > b", "changed"),
|
||||
p.restore(projected.replace("caption=\"a > b\"", "caption=\"changed\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPropertyReparentingInsteadOfSilentlySavingIt() throws Exception {
|
||||
var p =
|
||||
new FxmlProjection(
|
||||
"<?import javafx.scene.layout.BorderPane?><?import"
|
||||
+ " javafx.scene.control.Label?><BorderPane"
|
||||
+ " xmlns:fx='http://javafx.com/fxml/1'><left><Label"
|
||||
+ " fx:id='label'/></left></BorderPane>");
|
||||
String projected = p.project();
|
||||
assertThrows(
|
||||
Exception.class, () -> new FxmlProjection(XML.replace("fx:id=\"card\"", "")).project());
|
||||
Exception.class,
|
||||
() -> p.restore(projected.replace("<left>", "<right>").replace("</left>", "</right>")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addsAnAttachedConstraintImportWithoutReserializingTheDocument() throws Exception {
|
||||
String text =
|
||||
"<?xml version='1.0'?>\r\n"
|
||||
+ "<javafx.scene.layout.AnchorPane xmlns:fx='http://javafx.com/fxml/1'><app.Card"
|
||||
+ " fx:id='card' caption='same'/></javafx.scene.layout.AnchorPane>";
|
||||
var p = new FxmlProjection(text);
|
||||
String projected =
|
||||
p.project().replace("caption=\"same\"", "caption=\"same\" AnchorPane.leftAnchor=\"12\"");
|
||||
String restored = p.restore(projected);
|
||||
assertTrue(
|
||||
restored.startsWith(
|
||||
"<?xml version='1.0'?>\r\n<?import javafx.scene.layout.AnchorPane?>\r\n"));
|
||||
assertTrue(restored.contains("caption='same' AnchorPane.leftAnchor=\"12\""));
|
||||
assertTrue(restored.endsWith("</javafx.scene.layout.AnchorPane>"));
|
||||
var again = new FxmlProjection(restored);
|
||||
assertEquals(restored, again.restore(again.project()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
package de.neitzel.fxdesigner.kit;
|
||||
|
||||
import com.oracle.javafx.scenebuilder.kit.fxom.*;
|
||||
import de.neitzel.fxdesigner.contracts.*;
|
||||
import de.neitzel.fxdesigner.host.DocumentSession;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.*;
|
||||
|
||||
/**
|
||||
* Identical live UI/document scenarios used by native standalone and the isolated IDE companion.
|
||||
*/
|
||||
public final class ProjectionHostChecks {
|
||||
private static <T> T fx(Callable<T> task) throws Exception {
|
||||
var f = new CompletableFuture<T>();
|
||||
Platform.runLater(
|
||||
() -> {
|
||||
try {
|
||||
f.complete(task.call());
|
||||
} catch (Throwable e) {
|
||||
f.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return f.get(15, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static void await(Callable<Boolean> test) throws Exception {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(45);
|
||||
while (!test.call()) {
|
||||
if (System.nanoTime() > deadline) throw new AssertionError("Projection UI deadline");
|
||||
Thread.sleep(50);
|
||||
}
|
||||
}
|
||||
|
||||
private static void check(boolean ok, String message) {
|
||||
if (!ok) throw new AssertionError(message);
|
||||
}
|
||||
|
||||
private static TextArea source(DesignerPane pane) {
|
||||
return (TextArea)
|
||||
pane.lookupAll(".text-area").stream()
|
||||
.filter(n -> n instanceof TextArea t && t.isEditable())
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
|
||||
private static FXOMInstance object(DesignerPane pane, String id) {
|
||||
return (FXOMInstance) new FXOMFxIdIndex(pane.kit().editor().getFxomDocument()).lookup(id);
|
||||
}
|
||||
|
||||
private static void click(DesignerPane pane, String text) throws Exception {
|
||||
fx(
|
||||
() -> {
|
||||
((Button)
|
||||
pane.lookupAll(".button").stream()
|
||||
.filter(n -> n instanceof Button b && b.getText().equals(text))
|
||||
.findFirst()
|
||||
.orElseThrow())
|
||||
.fire();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private static void set(DocumentSession document, String text) throws Exception {
|
||||
var current = document.snapshot().toCompletableFuture().get();
|
||||
document
|
||||
.apply(
|
||||
new EditBatch(
|
||||
current.revision(),
|
||||
"Fixture source edit",
|
||||
List.of(new EditBatch.Replacement(0, current.text().length(), text))))
|
||||
.toCompletableFuture()
|
||||
.get();
|
||||
}
|
||||
|
||||
private static void mouse(
|
||||
javafx.scene.Node node,
|
||||
javafx.event.EventType<javafx.scene.input.MouseEvent> type,
|
||||
double dx,
|
||||
double dy,
|
||||
boolean down) {
|
||||
mouse(node, type, dx, dy, down, node);
|
||||
}
|
||||
|
||||
private static void mouse(
|
||||
javafx.scene.Node node,
|
||||
javafx.event.EventType<javafx.scene.input.MouseEvent> type,
|
||||
double dx,
|
||||
double dy,
|
||||
boolean down,
|
||||
javafx.scene.Node receiver) {
|
||||
var p =
|
||||
node.localToScene(
|
||||
node.getBoundsInLocal().getCenterX(), node.getBoundsInLocal().getCenterY(), true);
|
||||
var screen =
|
||||
node.localToScreen(
|
||||
node.getBoundsInLocal().getCenterX(), node.getBoundsInLocal().getCenterY());
|
||||
receiver.fireEvent(
|
||||
new javafx.scene.input.MouseEvent(
|
||||
type,
|
||||
p.getX() + dx,
|
||||
p.getY() + dy,
|
||||
screen.getX() + dx,
|
||||
screen.getY() + dy,
|
||||
javafx.scene.input.MouseButton.PRIMARY,
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
down,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
type != javafx.scene.input.MouseEvent.MOUSE_DRAGGED,
|
||||
new javafx.scene.input.PickResult(node, p.getX() + dx, p.getY() + dy)));
|
||||
}
|
||||
|
||||
public static void run(DesignerPane pane, DocumentSession document) throws Exception {
|
||||
String original = document.snapshot().toCompletableFuture().get().text();
|
||||
// The same real application, now with nested, fully-qualified Component declarations.
|
||||
String nested =
|
||||
"<?import javafx.scene.layout.AnchorPane?>\n"
|
||||
+ original
|
||||
.replace(
|
||||
"<Card fx:id=\"second\"",
|
||||
"<javafx.scene.layout.AnchorPane prefHeight='260'><fixture.components.Card"
|
||||
+ " fx:id=\"second\" AnchorPane.leftAnchor='12' AnchorPane.topAnchor='8'")
|
||||
.replace(
|
||||
"caption=\"Second\"/>",
|
||||
"caption=\"Second\"/></javafx.scene.layout.AnchorPane>");
|
||||
check(!nested.equals(original), "Fixture must add nested Component");
|
||||
set(document, nested);
|
||||
await(() -> fx(() -> source(pane).getText().equals(nested) && object(pane, "second") != null));
|
||||
fx(
|
||||
() -> {
|
||||
pane.kit().editor().getSelection().select(object(pane, "second"));
|
||||
return null;
|
||||
});
|
||||
await(() -> fx(() -> pane.lookup("#component-property-caption") != null));
|
||||
fx(
|
||||
() -> {
|
||||
var text = source(pane);
|
||||
int at = text.getText().indexOf("path=\"home\"");
|
||||
text.selectRange(at, at + 11);
|
||||
var field = (TextField) pane.lookup("#component-property-caption");
|
||||
field.setText("Nested & selected");
|
||||
field.fireEvent(new javafx.event.ActionEvent());
|
||||
return null;
|
||||
});
|
||||
String changed = nested.replace("caption=\"Second\"", "caption=\"Nested & selected\"");
|
||||
await(() -> document.snapshot().toCompletableFuture().get().text().equals(changed));
|
||||
await(() -> fx(() -> source(pane).getText().equals(changed)));
|
||||
check(
|
||||
fx(() -> source(pane).getSelectedText().equals("path=\"home\"")),
|
||||
"Unaffected source selection lost");
|
||||
click(pane, "Undo");
|
||||
await(() -> document.snapshot().toCompletableFuture().get().text().equals(nested));
|
||||
click(pane, "Redo");
|
||||
await(() -> document.snapshot().toCompletableFuture().get().text().equals(changed));
|
||||
await(() -> fx(() -> source(pane).getText().equals(changed)));
|
||||
click(pane, "Source");
|
||||
click(pane, "Design");
|
||||
click(pane, "Split");
|
||||
check(fx(() -> source(pane).getText().equals(changed)), "Mode switch changed source");
|
||||
click(pane, "Render drafts");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
object(pane, "second").getSceneGraphObject() instanceof SnapshotRegion r
|
||||
&& r.image() != null
|
||||
&& r.statusText().equals("Target runtime")));
|
||||
fx(
|
||||
() -> {
|
||||
var item = object(pane, "second");
|
||||
var node = (javafx.scene.Node) item.getSceneGraphObject();
|
||||
var point = node.localToScene(node.getBoundsInLocal().getCenterX(), 5, true);
|
||||
check(
|
||||
pane.content().pick(point.getX(), point.getY()) == item, "Native Component picking");
|
||||
pane.kit().editor().getSelection().select(item);
|
||||
pane.applyCss();
|
||||
pane.layout();
|
||||
return null;
|
||||
});
|
||||
Thread.sleep(150);
|
||||
fx(
|
||||
() -> {
|
||||
var handles = pane.content().lookupHandles(object(pane, "second"));
|
||||
check(
|
||||
handles
|
||||
instanceof
|
||||
com.oracle.javafx.scenebuilder.kit.editor.panel.content.driver.handles
|
||||
.AbstractGenericHandles<
|
||||
?>,
|
||||
"Native resize handles missing");
|
||||
var handle =
|
||||
((com.oracle.javafx.scenebuilder.kit.editor.panel.content.driver.handles
|
||||
.AbstractGenericHandles<
|
||||
?>)
|
||||
handles)
|
||||
.getHandleNode(
|
||||
com.oracle.javafx.scenebuilder.kit.editor.panel.content.util.CardinalPoint
|
||||
.SE);
|
||||
mouse(handle, javafx.scene.input.MouseEvent.MOUSE_PRESSED, 0, 0, true);
|
||||
mouse(handle, javafx.scene.input.MouseEvent.DRAG_DETECTED, 20, 15, true);
|
||||
mouse(handle, javafx.scene.input.MouseEvent.MOUSE_DRAGGED, 20, 15, true);
|
||||
mouse(handle, javafx.scene.input.MouseEvent.MOUSE_RELEASED, 20, 15, false);
|
||||
return null;
|
||||
});
|
||||
await(() -> !document.snapshot().toCompletableFuture().get().text().equals(changed));
|
||||
String resized = document.snapshot().toCompletableFuture().get().text();
|
||||
check(
|
||||
resized.contains("prefHeight=") && resized.contains("fixture.components.Card"),
|
||||
"Resize must preserve real Component declaration");
|
||||
click(pane, "Undo");
|
||||
await(() -> document.snapshot().toCompletableFuture().get().text().equals(changed));
|
||||
await(() -> fx(() -> source(pane).getText().equals(changed)));
|
||||
// Exercise the exact Kit relocation command used after a canvas move. Native OS
|
||||
// dragboard delivery is a separate manual/platform qualification, not synthesized here.
|
||||
fx(
|
||||
() -> {
|
||||
var item = object(pane, "second");
|
||||
pane.kit().editor().getSelection().select(item);
|
||||
pane.kit()
|
||||
.editor()
|
||||
.getJobManager()
|
||||
.push(
|
||||
new com.oracle.javafx.scenebuilder.kit.editor.job.RelocateSelectionJob(
|
||||
Map.of(item, new javafx.geometry.Point2D(42, 18)), pane.kit().editor()));
|
||||
return null;
|
||||
});
|
||||
await(() -> !document.snapshot().toCompletableFuture().get().text().equals(changed));
|
||||
check(
|
||||
document.snapshot().toCompletableFuture().get().text().contains("AnchorPane.leftAnchor"),
|
||||
"Attached constraints lost during move");
|
||||
click(pane, "Undo");
|
||||
await(() -> document.snapshot().toCompletableFuture().get().text().equals(changed));
|
||||
await(() -> fx(() -> source(pane).getText().equals(changed)));
|
||||
var snapshot = document.snapshot().toCompletableFuture().get();
|
||||
document.save(snapshot.revision()).toCompletableFuture().get();
|
||||
check(
|
||||
java.nio.file.Files.readString(java.nio.file.Path.of(snapshot.document())).equals(changed),
|
||||
"Save reformatted source");
|
||||
try (var reopened =
|
||||
pane.kit().host().documents().open(snapshot.document()).toCompletableFuture().get()) {
|
||||
check(
|
||||
reopened.snapshot().toCompletableFuture().get().text().equals(changed),
|
||||
"Reopen lost source");
|
||||
}
|
||||
String anonymous =
|
||||
changed.replace(
|
||||
"</javafx.scene.layout.AnchorPane>",
|
||||
"<fixture.components.Card caption='Anonymous'"
|
||||
+ " layoutY='240'/></javafx.scene.layout.AnchorPane>");
|
||||
var anonymousProjection = new FxmlProjection(anonymous);
|
||||
anonymousProjection.project();
|
||||
String anonymousId =
|
||||
anonymousProjection.components().keySet().stream()
|
||||
.filter(id -> id.startsWith("__designer_"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
set(document, anonymous);
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
source(pane).getText().equals(anonymous) && object(pane, anonymousId) != null));
|
||||
click(pane, "Render drafts");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
object(pane, anonymousId).getSceneGraphObject() instanceof SnapshotRegion r
|
||||
&& r.image() != null
|
||||
&& r.statusText().equals("Target runtime")));
|
||||
check(
|
||||
!document.snapshot().toCompletableFuture().get().text().contains("__designer_"),
|
||||
"Preview identity leaked into source");
|
||||
set(document, changed);
|
||||
await(() -> fx(() -> source(pane).getText().equals(changed)));
|
||||
// Incomplete source retains the old canvas, but blocks visual editing until recovery.
|
||||
set(document, changed + "<");
|
||||
await(() -> fx(() -> source(pane).getText().equals(changed + "<")));
|
||||
check(
|
||||
fx(() -> ((javafx.scene.Node) object(pane, "second").getSceneGraphObject()).isDisabled()),
|
||||
"Invalid source leaves canvas editable");
|
||||
set(document, original);
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
source(pane).getText().equals(original)
|
||||
&& !((javafx.scene.Node) object(pane, "second").getSceneGraphObject())
|
||||
.isDisabled()));
|
||||
// fx:root Component editing needs no application class in this host.
|
||||
String root =
|
||||
"<?import javafx.scene.layout.VBox?><?import javafx.scene.control.Label?><fx:root"
|
||||
+ " type='javafx.scene.layout.VBox' xmlns:fx='http://javafx.com/fxml/1'><Label"
|
||||
+ " text='root text'/></fx:root>";
|
||||
set(document, root);
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
source(pane).getText().equals(root)
|
||||
&& pane.kit()
|
||||
.editor()
|
||||
.getFxomDocument()
|
||||
.getFxmlText(false)
|
||||
.contains("root text")));
|
||||
fx(
|
||||
() -> {
|
||||
var item = object(pane, "__designer_1");
|
||||
pane.kit().editor().getSelection().select(item);
|
||||
return null;
|
||||
});
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
pane.lookupAll(".text-field").stream()
|
||||
.anyMatch(
|
||||
n -> n instanceof TextField t && t.getText().equals("root text"))));
|
||||
fx(
|
||||
() -> {
|
||||
var field =
|
||||
(TextField)
|
||||
pane.lookupAll(".text-field").stream()
|
||||
.filter(n -> n instanceof TextField t && t.getText().equals("root text"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
field.setText("root changed");
|
||||
field.fireEvent(new javafx.event.ActionEvent());
|
||||
source(pane).appendText("<!-- queued source edit -->");
|
||||
return null;
|
||||
});
|
||||
await(
|
||||
() ->
|
||||
document
|
||||
.snapshot()
|
||||
.toCompletableFuture()
|
||||
.get()
|
||||
.text()
|
||||
.equals(root.replace("root text", "root changed") + "<!-- queued source edit -->"));
|
||||
set(document, original);
|
||||
await(() -> fx(() -> source(pane).getText().equals(original) && object(pane, "card") != null));
|
||||
document
|
||||
.save(document.snapshot().toCompletableFuture().get().revision())
|
||||
.toCompletableFuture()
|
||||
.get();
|
||||
click(pane, "Render drafts");
|
||||
await(
|
||||
() ->
|
||||
fx(
|
||||
() ->
|
||||
object(pane, "card").getSceneGraphObject() instanceof SnapshotRegion r
|
||||
&& r.image() != null
|
||||
&& r.statusText().equals("Target runtime")));
|
||||
System.out.println(
|
||||
"PASS shared projection: exact source, picking/resize/relocation, nested/FQ/anonymous"
|
||||
+ " Components, declared properties, selection, undo/redo, modes, target load,"
|
||||
+ " save/reopen, invalid recovery, fx:root");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user