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
@@ -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 &amp; 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");
}
}