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 1426bb2..5fedf6b 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 @@ -109,7 +109,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { private DocumentSnapshot draft; private FxmlProjection projection; private DesignerWorkflow workflow; - private boolean loading, closed, pending, last; + private boolean loading, closed, pending, last, canvasCommitQueued; + private long canvasGeneration; private final java.util.concurrent.atomic.AtomicLong renderSequence = new java.util.concurrent.atomic.AtomicLong(); private AutoCloseable changes; @@ -205,6 +206,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { .addListener( (o, a, b) -> { if (!loading) { + flushCanvas(); if (pending && submittedSource == null) submittedSource = pendingBase; commitSource(); workflow.cancel(); @@ -338,6 +340,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { private void load(DocumentSnapshot value) { if (closed) return; loading = true; + canvasCommitQueued = false; + canvasGeneration++; renderSequence.incrementAndGet(); workflow.cancel(); try { @@ -441,6 +445,28 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { } private void canvasChanged() { + if (loading || pending || closed || canvasCommitQueued) return; + // Kit's corner-resize gesture pushes one job per dimension on the same FX event turn. + // Capture the completed gesture, not the intermediate width-only document. + canvasCommitQueued = true; + long generation = ++canvasGeneration; + var projectedDocument = kit.editor().getFxomDocument(); + var expectedDraft = draft; + Platform.runLater( + () -> { + if (generation != canvasGeneration || closed) return; + if (projectedDocument != kit.editor().getFxomDocument() || draft != expectedDraft) { + canvasCommitQueued = false; + return; + } + flushCanvas(); + }); + } + + private void flushCanvas() { + if (!canvasCommitQueued) return; + canvasCommitQueued = false; + canvasGeneration++; if (loading || pending || closed) return; try { submit( @@ -454,6 +480,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { private void withDraft(Runnable action) { if (document == null) return; + flushCanvas(); if (pending || !source.getText().equals(draft.text())) { commitSource(); status.setText("Finishing source edit; repeat the action when synchronized"); @@ -689,6 +716,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { } private void flushInto(CompletableFuture result, long deadline) { + flushCanvas(); if (System.nanoTime() > deadline) { result.completeExceptionally( new IllegalStateException("Source edit could not be synchronized")); diff --git a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java index 66775b5..ab8ac50 100644 --- a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java +++ b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/ProjectionHostChecks.java @@ -118,6 +118,95 @@ public final class ProjectionHostChecks { new javafx.scene.input.PickResult(node, p.getX() + dx, p.getY() + dy))); } + public static void resizeCorner(DesignerPane pane, DocumentSession document) throws Exception { + String before = document.snapshot().toCompletableFuture().get().text(); + fx( + () -> { + pane.kit().editor().getSelection().select(object(pane, "second")); + pane.applyCss(); + pane.layout(); + return null; + }); + Thread.sleep(150); + double initialWidth = + fx(() -> ((SnapshotRegion) object(pane, "second").getSceneGraphObject()).getWidth()); + double initialHeight = + fx(() -> ((SnapshotRegion) object(pane, "second").getSceneGraphObject()).getHeight()); + 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(before)); + String resized = document.snapshot().toCompletableFuture().get().text(); + check( + resized.contains("prefHeight=") && resized.contains("Card"), + "Resize must preserve real Component declaration"); + var parser = javax.xml.parsers.DocumentBuilderFactory.newInstance(); + parser.setNamespaceAware(true); + parser.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + var resizedXml = + parser + .newDocumentBuilder() + .parse(new org.xml.sax.InputSource(new java.io.StringReader(resized))); + var resizedNodes = resizedXml.getElementsByTagName("*"); + org.w3c.dom.Element resizedCard = null; + for (int i = 0; i < resizedNodes.getLength(); i++) { + var candidate = (org.w3c.dom.Element) resizedNodes.item(i); + if (candidate.getAttributeNS("http://javafx.com/fxml/1", "id").equals("second")) + resizedCard = candidate; + } + String storedHeight = resizedCard.getAttribute("prefHeight"); + check( + !storedHeight.isBlank() && Double.parseDouble(storedHeight) > initialHeight, + "Corner resize lost height: before=" + + initialHeight + + " stored=" + + storedHeight + + " source=" + + resized); + String storedWidth = resizedCard.getAttribute("prefWidth"); + check( + !storedWidth.isBlank() && Double.parseDouble(storedWidth) > initialWidth, + "Corner resize lost width: before=" + initialWidth + " stored=" + storedWidth); + await( + () -> + fx( + () -> + source(pane).getText().equals(resized) + && ((SnapshotRegion) object(pane, "second").getSceneGraphObject()) + .getHeight() + > initialHeight)); + click(pane, "Undo"); + await(() -> document.snapshot().toCompletableFuture().get().text().equals(before)); + await(() -> fx(() -> source(pane).getText().equals(before))); + click(pane, "Redo"); + await(() -> document.snapshot().toCompletableFuture().get().text().equals(resized)); + await(() -> fx(() -> source(pane).getText().equals(resized))); + click(pane, "Undo"); + await(() -> document.snapshot().toCompletableFuture().get().text().equals(before)); + await(() -> fx(() -> source(pane).getText().equals(before))); + } + 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. @@ -185,39 +274,7 @@ public final class ProjectionHostChecks { 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))); + resizeCorner(pane, document); // 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( @@ -243,7 +300,11 @@ public final class ProjectionHostChecks { document.save(snapshot.revision()).toCompletableFuture().get(); check( java.nio.file.Files.readString(java.nio.file.Path.of(snapshot.document())).equals(changed), - "Save reformatted source"); + "Save reformatted source: expected=[" + + changed + + "] actual=[" + + java.nio.file.Files.readString(java.nio.file.Path.of(snapshot.document())) + + "]"); try (var reopened = pane.kit().host().documents().open(snapshot.document()).toCompletableFuture().get()) { check( diff --git a/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/CornerResizeSmoke.java b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/CornerResizeSmoke.java new file mode 100644 index 0000000..4452c76 --- /dev/null +++ b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/CornerResizeSmoke.java @@ -0,0 +1,59 @@ +package de.neitzel.fxdesigner.standalone; + +import de.neitzel.fxdesigner.kit.*; +import de.neitzel.fxdesigner.workflow.HostLaunch; +import java.nio.file.*; +import java.util.concurrent.*; +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.stage.Stage; + +/** Minimal actual Kit corner gesture -> asynchronous host document regression, no worker/build. */ +public final class CornerResizeSmoke { + public static void main(String[] args) throws Exception { + var started = new CountDownLatch(1); + Platform.startup(started::countDown); + started.await(); + var launch = HostLaunch.load(Path.of(args[0])); + Path file = Files.createTempFile("corner-resize-", ".fxml"); + Files.writeString( + file, + ""); + try (var host = + new StandaloneHost( + launch.runtimes(), + l -> CompletableFuture.completedFuture(null), + d -> System.out.println(d.message()))) { + KitWorkflowSmoke.fx( + () -> { + KitWorkflowSmoke.pane = new DesignerPane(host.services(), launch, file); + KitWorkflowSmoke.stage = new Stage(); + KitWorkflowSmoke.stage.setScene(new Scene(KitWorkflowSmoke.pane, 1300, 850)); + KitWorkflowSmoke.stage.show(); + return null; + }); + long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (!KitWorkflowSmoke.fx( + () -> KitWorkflowSmoke.pane.kit().editor().getFxomDocument() != null)) { + if (System.nanoTime() > until) throw new AssertionError("Projection failed"); + Thread.sleep(25); + } + try (var doc = host.services().documents().open(file.toUri()).toCompletableFuture().get()) { + ProjectionHostChecks.resizeCorner(KitWorkflowSmoke.pane, doc); + } + System.out.println("PASS corner resize commits height and width in one undo transaction"); + } finally { + KitWorkflowSmoke.fx( + () -> { + if (KitWorkflowSmoke.pane != null) KitWorkflowSmoke.pane.close(); + if (KitWorkflowSmoke.stage != null) KitWorkflowSmoke.stage.close(); + return null; + }); + Platform.exit(); + Files.deleteIfExists(file); + } + } +} diff --git a/docs/source-preserving-projection.md b/docs/source-preserving-projection.md index 13bb8bc..ecaa026 100644 --- a/docs/source-preserving-projection.md +++ b/docs/source-preserving-projection.md @@ -51,6 +51,20 @@ Verified on 2026-11-12: **65 tests** (58 Maven, seven IntelliJ), zero failures/e Physical OS drag-and-drop delivery is **not qualified by this automated result**. Synthetic mouse events cannot start JavaFX's native dragboard; a desktop Robot attempt did not result in a document move, including after bringing the test process to the foreground. The suite therefore tests the actual Kit relocation command separately from resize-handle events. A human drag/drop check in both hosts remains part of stage-2 platform qualification. +## Corner-resize correction and manual review + +Human standalone review confirmed independent dragging of both Components, one-click Undo/Redo, save and Source/Design preservation, and separate width/height edge resizing. Corner resizing exposed a dropped height update: Kit emits a separate synchronous job for each dimension, and the first asynchronous host write caused the second notification to be ignored. The shared UI now collects those notifications until the next FX event turn and submits the final geometry as one source-preserving undo transaction. Source edits, document actions and close synchronization flush any queued canvas edit; projection reloads invalidate stale callbacks. + +The regression checks the selected Component's own width and height (the previous whole-document height check could match its ancestor), the reloaded canvas height, and exact source through one Undo and Redo. It runs through the shared live host scenarios. A small standalone reproducer can also run after the Maven build without Build and Refresh or a target worker: + +```sh +python3 scripts/check-corner-resize.py --target-java-home "$TARGET_JAVA_HOME" +``` + +The focused regression, Maven tests, IntelliJ adapter tests/plugin build, both connected standalone layouts and both registered IntelliJ layouts passed with the correction. One earlier IDE run failed the later save-preservation assertion after the corner checks passed; this did not reproduce on the next complete run. The save assertion now reports expected and actual source for investigation if it recurs. + +The corner fix still needs the user's physical mouse retest. IntelliJ physical drag/drop review remains pending. The automated resize gesture is evidence for the transaction fix, not a substitute for these manual checks. + ## Remaining boundaries This stage preserves arbitrary source, but does not make arbitrary structure visually editable. Insert/delete/reparent, binding-aware renames and subtree copy/paste require later structural transactions. Application wildcard imports, `fx:include`, scripts and unrepresentable roots retain source fallback. Arbitrary target properties use declared FXML text editing until shared discovery supplies typed metadata. Anonymous identities are local to a projection revision, not the persistent occurrence identities needed for repeated/Table Components. Target node lookup still uses the initial CSS-ID route; separate CSS IDs and repeated/internal scopes need stage 3's occurrence identity work. diff --git a/scripts/check-corner-resize.py b/scripts/check-corner-resize.py new file mode 100644 index 0000000..1011cc0 --- /dev/null +++ b/scripts/check-corner-resize.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Replay a real Kit corner gesture through the standalone document adapter, without a build/worker.""" +import argparse +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--target-java-home', required=True) +args = parser.parse_args() +classpath = os.pathsep.join([str(ROOT / 'designer-standalone/target/test-classes'), + str(ROOT / 'designer-standalone/target/classes'), + (ROOT / 'designer-standalone/target/smoke-classpath.txt').read_text().strip()]) +with tempfile.TemporaryDirectory(prefix='designer-corner-') as temporary: + project = Path(temporary) / 'project' + subprocess.run([sys.executable, ROOT / 'scripts/prepare-fixture-host.py', '--destination', project, + '--target-java-home', args.target_java_home], cwd=ROOT, check=True, stdout=subprocess.DEVNULL) + subprocess.run([Path(os.environ['JAVA_HOME']) / 'bin/java', '--enable-native-access=ALL-UNNAMED', + '-cp', classpath, 'de.neitzel.fxdesigner.standalone.CornerResizeSmoke', + project / '.neitzel-fx-designer/host.properties'], cwd=ROOT, check=True, timeout=30)