Connect minimal hosts to snapshots, target workers and shared Kit editing

This commit is contained in:
2026-11-12 11:07:45 +01:00
parent 28c1e47c95
commit 7db7218a6e
52 changed files with 4236 additions and 29 deletions
@@ -0,0 +1,193 @@
package de.neitzel.fxdesigner.standalone;
import de.neitzel.fxdesigner.kit.DesignerPane;
import de.neitzel.fxdesigner.workflow.HostLaunch;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.stage.Stage;
/** Development standalone entry: launch settings file and initial FXML path. */
public final class DesignerApplication extends Application {
private HostLaunch launch;
private StandaloneHost host;
private int windows;
@Override
public void start(Stage stage) throws Exception {
var args = getParameters().getRaw();
if (args.size() != 2)
throw new IllegalArgumentException("Arguments: local-launch.properties View.fxml");
try {
launch = HostLaunch.load(Path.of(args.get(0)));
open(stage, Path.of(args.get(1)));
} catch (Exception setup) {
if (host != null) host.close();
host =
new StandaloneHost(
(name, role) -> CompletableFuture.failedFuture(setup),
location -> CompletableFuture.failedFuture(setup),
diagnostic -> System.err.println(diagnostic.message()));
var source =
new de.neitzel.fxdesigner.kit.SourceOnlyPane(
host.services(), Path.of(args.get(1)), String.valueOf(setup.getMessage()));
stage.setTitle("Neitzel FX Designer — source");
stage.setScene(new Scene(source, 1000, 700));
stage.setOnCloseRequest(
event -> {
if (!source.dirty()) return;
event.consume();
var save = new javafx.scene.control.ButtonType("Save");
var discard = new javafx.scene.control.ButtonType("Discard");
var choice =
new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.CONFIRMATION,
"Unsaved FXML draft",
save,
discard,
javafx.scene.control.ButtonType.CANCEL)
.showAndWait()
.orElse(javafx.scene.control.ButtonType.CANCEL);
if (choice == discard) {
stage.setOnCloseRequest(null);
stage.close();
} else if (choice == save)
source
.save()
.whenComplete(
(v, error) ->
javafx.application.Platform.runLater(
() -> {
if (error == null) {
stage.setOnCloseRequest(null);
stage.close();
}
}));
});
stage.setOnHidden(
event -> {
source.close();
host.close();
});
stage.show();
}
}
private void open(Stage stage, Path file) throws Exception {
if (host == null)
host =
new StandaloneHost(
launch.runtimes(),
location -> {
var future = new CompletableFuture<Void>();
javafx.application.Platform.runLater(
() -> {
try {
open(new Stage(), Path.of(location.document()));
future.complete(null);
} catch (Exception e) {
future.completeExceptionally(e);
}
});
return future;
},
d -> System.err.println(d.message()));
var pane = new DesignerPane(host.services(), launch, file);
stage.setTitle("Neitzel FX Designer — " + file.getFileName());
stage.setScene(new Scene(pane, 1300, 850));
windows++;
boolean[] closing = {false};
stage.setOnCloseRequest(
event -> {
event.consume();
if (closing[0]) return;
closing[0] = true;
pane.flush()
.thenCompose(v -> host.services().documents().unsaved())
.whenComplete(
(drafts, error) ->
javafx.application.Platform.runLater(
() -> {
if (error != null) {
closing[0] = false;
new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.ERROR,
error.getMessage())
.show();
return;
}
if (windows > 1 || drafts.isEmpty()) {
stage.setOnCloseRequest(null);
stage.close();
return;
}
var save = new javafx.scene.control.ButtonType("Save all");
var discard = new javafx.scene.control.ButtonType("Discard drafts");
var dialog =
new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.CONFIRMATION,
String.join(
"\n",
drafts.stream().map(d -> d.document().toString()).toList()),
save,
discard,
javafx.scene.control.ButtonType.CANCEL);
dialog.setHeaderText("Unsaved FXML drafts");
var choice =
dialog.showAndWait().orElse(javafx.scene.control.ButtonType.CANCEL);
if (choice == javafx.scene.control.ButtonType.CANCEL) {
closing[0] = false;
return;
}
if (choice == discard) {
stage.setOnCloseRequest(null);
stage.close();
return;
}
Thread.ofVirtual()
.start(
() -> {
try {
for (var draft : drafts)
try (var document =
host.services()
.documents()
.open(draft.document())
.toCompletableFuture()
.get()) {
document
.save(draft.revision())
.toCompletableFuture()
.get();
}
javafx.application.Platform.runLater(
() -> {
stage.setOnCloseRequest(null);
stage.close();
});
} catch (Exception failure) {
javafx.application.Platform.runLater(
() -> {
closing[0] = false;
new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.ERROR,
"Save failed: " + failure.getMessage())
.show();
});
}
});
}));
});
stage.setOnHidden(
e -> {
pane.close();
if (--windows == 0) host.close();
});
stage.show();
}
public static void main(String[] args) {
launch(args);
}
}
@@ -14,6 +14,14 @@ import java.util.concurrent.*;
public final class FileDocuments implements HostServices.Documents, AutoCloseable {
private final ExecutorService executor =
Executors.newSingleThreadExecutor(Thread.ofVirtual().factory());
private final java.util.concurrent.CopyOnWriteArrayList<java.util.function.Consumer<URI>>
changes = new java.util.concurrent.CopyOnWriteArrayList<>();
public AutoCloseable onChange(java.util.function.Consumer<URI> listener) {
changes.add(listener);
return () -> changes.remove(listener);
}
private final Map<Path, State> documents = new HashMap<>();
private static final class State {
@@ -55,6 +63,10 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl
});
}
public CompletionStage<List<DocumentSnapshot>> snapshots() {
return submit(() -> documents.values().stream().map(FileDocuments::snapshot).toList());
}
public CompletionStage<List<DocumentSnapshot>> unsaved() {
return submit(
() ->
@@ -98,6 +110,7 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl
s.redo.clear();
s.text = updated;
s.revision++;
changes.forEach(listener -> listener.accept(s.file.toUri()));
}
return FileDocuments.snapshot(s);
});
@@ -120,6 +133,7 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl
to.push(s.text);
s.text = from.pop();
s.revision++;
changes.forEach(listener -> listener.accept(s.file.toUri()));
}
return FileDocuments.snapshot(s);
});
@@ -0,0 +1,8 @@
package de.neitzel.fxdesigner.standalone;
/** Plain launcher supports the development classpath packaging of JavaFX. */
public final class Launcher {
public static void main(String[] args) {
javafx.application.Application.launch(DesignerApplication.class, args);
}
}
@@ -0,0 +1,269 @@
package de.neitzel.fxdesigner.standalone;
import com.oracle.javafx.scenebuilder.kit.fxom.*;
import de.neitzel.fxdesigner.kit.*;
import de.neitzel.fxdesigner.workflow.*;
import java.nio.file.*;
import java.util.concurrent.*;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.stage.Stage;
public final class KitWorkflowSmoke {
static DesignerPane pane;
static Stage stage;
static de.neitzel.fxdesigner.kit.SourceOnlyPane fallback;
static <T> T fx(Callable<T> action) throws Exception {
var result = new CompletableFuture<T>();
Platform.runLater(
() -> {
try {
result.complete(action.call());
} catch (Throwable e) {
result.completeExceptionally(e);
}
});
return result.get(15, TimeUnit.SECONDS);
}
static void click(String label) throws Exception {
fx(
() -> {
((Button)
pane.lookupAll(".button").stream()
.filter(n -> n instanceof Button b && b.getText().equals(label))
.findFirst()
.orElseThrow())
.fire();
return null;
});
}
static void waitText(de.neitzel.fxdesigner.host.DocumentSession doc, String text)
throws Exception {
long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(8);
while (System.nanoTime() < until) {
if (doc.snapshot().toCompletableFuture().get().text().contains(text)) return;
Thread.sleep(50);
}
throw new AssertionError("Document did not reach " + text);
}
public static void main(String[] args) throws Exception {
var started = new CountDownLatch(1);
Platform.startup(started::countDown);
started.await();
Platform.setImplicitExit(false);
var launch = HostLaunch.load(Path.of(args[0]));
try (var host =
new StandaloneHost(
launch.runtimes(),
l -> CompletableFuture.completedFuture(null),
d -> System.out.println(d.message()))) {
fx(
() -> {
pane = new DesignerPane(host.services(), launch, Path.of(args[1]));
stage = new Stage();
stage.setTitle("Neitzel FX Designer — workflow qualification");
stage.setScene(new Scene(pane, 1300, 850));
stage.show();
return null;
});
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
while (!fx(() -> pane.kit().editor().getFxomDocument() != null)
&& System.nanoTime() < deadline) Thread.sleep(100);
fx(
() -> {
if (pane.kit().editor().getFxomDocument() == null)
throw new AssertionError("Kit projection unavailable");
return null;
});
click("Build and Refresh");
deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(90);
boolean rendered = false;
while (System.nanoTime() < deadline) {
rendered =
fx(
() ->
java.util.List.of("card", "second").stream()
.allMatch(
id -> {
var obj =
new FXOMFxIdIndex(pane.kit().editor().getFxomDocument())
.lookup(id);
return obj instanceof FXOMInstance instance
&& instance.getSceneGraphObject() instanceof SnapshotRegion region
&& region.image() != null
&& region.statusText().equals("Target runtime");
}));
if (rendered) break;
Thread.sleep(100);
}
if (!rendered) {
System.err.println(
fx(
() ->
pane.lookupAll(".label").stream()
.filter(n -> n instanceof Label)
.map(n -> ((Label) n).getText())
.toList()));
throw new AssertionError("Shared Kit did not render target pixels");
}
fx(
() -> {
var card = new FXOMFxIdIndex(pane.kit().editor().getFxomDocument()).lookup("card");
pane.kit().editor().getSelection().select(card);
return null;
});
Thread.sleep(400);
fx(
() -> {
var card =
(FXOMInstance)
new FXOMFxIdIndex(pane.kit().editor().getFxomDocument()).lookup("card");
String caption = ((SnapshotRegion) card.getSceneGraphObject()).getCaption();
var field =
(TextField)
pane.lookupAll(".text-field").stream()
.filter(n -> n instanceof TextField t && t.getText().equals(caption))
.findFirst()
.orElseThrow();
field.setText("alpha bravo charlie");
pane.applyCss();
pane.layout();
var result = new StringBuilder();
TextSelectionChecks.run(field, result);
System.out.print(result);
field.setText(caption);
field.fireEvent(new javafx.event.ActionEvent());
return null;
});
fx(
() -> {
var type =
com.oracle.javafx.scenebuilder.kit.editor.panel.inspector.editors.I18nStringEditor
.class;
if (!type.getProtectionDomain()
.getCodeSource()
.getLocation()
.toString()
.contains("designer-kit-ui"))
throw new AssertionError("Unpatched Kit editor loaded");
var snapshot = pane.snapshot(null, null);
javax.imageio.ImageIO.write(
javafx.embed.swing.SwingFXUtils.fromFXImage(snapshot, null),
"png",
Path.of(args[2]).toFile());
return null;
});
String originalCaption =
fx(
() -> {
var editor = pane.kit().editor();
var card =
(FXOMInstance) new FXOMFxIdIndex(editor.getFxomDocument()).lookup("card");
var property =
(FXOMPropertyT)
card.getProperties()
.get(
new com.oracle.javafx.scenebuilder.kit.metadata.util.PropertyName(
"caption"));
String old = property.getValue();
editor
.getJobManager()
.push(
new com.oracle.javafx.scenebuilder.kit.editor.job.Job(editor) {
public boolean isExecutable() {
return true;
}
public String getDescription() {
return "Caption";
}
public void execute() {
set("Native canvas edit");
}
public void undo() {
set(old);
}
public void redo() {
execute();
}
private void set(String value) {
var document = editor.getFxomDocument();
document.beginUpdate();
try {
property.setValue(value);
} finally {
document.endUpdate();
}
}
});
return old;
});
try (var document =
host.services().documents().open(Path.of(args[1]).toUri()).toCompletableFuture().get()) {
waitText(document, "Native canvas edit");
click("Undo");
waitText(document, originalCaption);
click("Redo");
waitText(document, "Native canvas edit");
if (!document.snapshot().toCompletableFuture().get().text().contains("path=\"home\""))
throw new AssertionError("Binding metadata lost");
click("Save");
long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (!Files.readString(Path.of(args[1])).contains("Native canvas edit")
&& System.nanoTime() < until) Thread.sleep(50);
if (!Files.readString(Path.of(args[1])).contains("Native canvas edit"))
throw new AssertionError("Host save missing");
}
fx(
() -> {
pane.close();
fallback =
new de.neitzel.fxdesigner.kit.SourceOnlyPane(
host.services(), Path.of(args[1]), "Unsupported configuration fixture");
stage.setScene(new Scene(fallback, 1000, 700));
return null;
});
long readyUntil = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (!fx(() -> ((TextArea) fallback.getCenter()).isEditable())
&& System.nanoTime() < readyUntil) Thread.sleep(50);
fx(
() -> {
var text = (TextArea) fallback.getCenter();
text.appendText("<!-- source remains editable -->");
((Button)
fallback.lookupAll(".button").stream()
.filter(n -> n instanceof Button b && b.getText().equals("Save"))
.findFirst()
.orElseThrow())
.fire();
return null;
});
long savedUntil = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (!Files.readString(Path.of(args[1])).contains("source remains editable")
&& System.nanoTime() < savedUntil) Thread.sleep(50);
if (!Files.readString(Path.of(args[1])).contains("source remains editable"))
throw new AssertionError("Source fallback did not save");
System.out.println(
"PASS native shared Kit: projection, Build and Refresh, worker pixels, selection, patched"
+ " class and snapshot");
} finally {
fx(
() -> {
if (pane != null) pane.close();
if (fallback != null) fallback.close();
if (stage != null) stage.close();
return null;
});
Platform.exit();
}
}
}
@@ -0,0 +1,90 @@
package de.neitzel.fxdesigner.standalone;
import javafx.event.Event;
import javafx.scene.control.TextInputControl;
import javafx.scene.input.*;
import javafx.scene.text.Text;
/** Shared real-control mouse event assertions; native device checks are separate. */
public final class TextSelectionChecks {
static void mouse(
TextInputControl t,
javafx.event.EventType<MouseEvent> type,
int count,
int index,
boolean down) {
Text text = (Text) t.lookup(".text");
Text prefix = new Text(t.getText().substring(0, index));
prefix.setFont(t.getFont());
var point =
text.localToScene(
prefix.getLayoutBounds().getWidth() + 1, text.getLayoutBounds().getCenterY());
var screen = t.getScene().getRoot().localToScreen(point);
Event.fireEvent(
t,
new MouseEvent(
type,
point.getX(),
point.getY(),
screen.getX(),
screen.getY(),
MouseButton.PRIMARY,
count,
false,
false,
false,
false,
down,
false,
false,
false,
false,
type != MouseEvent.MOUSE_DRAGGED,
new PickResult(t, point.getX(), point.getY())));
}
static void click(TextInputControl t, int count, int index) {
mouse(t, MouseEvent.MOUSE_PRESSED, count, index, true);
mouse(t, MouseEvent.MOUSE_RELEASED, count, index, false);
}
public static void run(TextInputControl t, StringBuilder result) {
t.requestFocus();
t.positionCaret(t.getLength());
click(t, 1, 7);
int caret = t.getCaretPosition();
boolean single = t.getSelection().getLength() == 0 && caret > 0 && caret < t.getLength();
result.append((single ? "PASS" : "FAIL") + " single click: " + t.getSelection() + "\n");
t.positionCaret(t.getLength());
click(t, 1, 8);
click(t, 2, 8);
boolean word = "bravo".equals(t.getSelectedText());
result.append((word ? "PASS" : "FAIL") + " double click: " + t.getSelectedText() + "\n");
t.positionCaret(t.getLength());
mouse(t, MouseEvent.MOUSE_PRESSED, 1, 7, true);
mouse(t, MouseEvent.MOUSE_DRAGGED, 1, 10, true);
mouse(t, MouseEvent.MOUSE_RELEASED, 1, 10, false);
boolean drag =
t.getSelection().getStart() > 0
&& t.getSelection().getEnd() < t.getLength()
&& t.getSelection().getLength() > 0;
result.append((drag ? "PASS" : "FAIL") + " interior drag: " + t.getSelection() + "\n");
t.positionCaret(0);
mouse(t, MouseEvent.MOUSE_PRESSED, 1, 10, true);
mouse(t, MouseEvent.MOUSE_DRAGGED, 1, 7, true);
mouse(t, MouseEvent.MOUSE_RELEASED, 1, 7, false);
boolean reverse = t.getAnchor() == 10 && t.getCaretPosition() == 7;
result.append(
(reverse ? "PASS" : "FAIL")
+ " reverse drag: anchor="
+ t.getAnchor()
+ " caret="
+ t.getCaretPosition()
+ "\n");
click(t, 3, 8);
boolean triple = t.getSelectedText().equals(t.getText());
result.append((triple ? "PASS" : "FAIL") + " triple click retains select-all\n");
if (!single || !word || !drag || !reverse || !triple)
throw new AssertionError("Kit must preserve native text selection");
}
}
@@ -0,0 +1,26 @@
package de.neitzel.fxdesigner.standalone;
import de.neitzel.fxdesigner.build.*;
import de.neitzel.fxdesigner.workflow.*;
import java.nio.file.*;
import java.util.*;
/** Separate owner JVM for an actual abrupt-owner-death regression. */
public final class WorkerOwnerProbe {
public static void main(String[] args) throws Exception {
Path root = Path.of(args[0]), fixture = Path.of(args[1]), pid = Path.of(args[2]);
var store = new SnapshotStore(fixture.resolve(".neitzel-fx-designer/snapshots"));
try (var worker =
new PreviewWorker(
new DraftOverlay(store.lease(store.state("fixture").lastSuccessful()), List.of()))) {
worker.start(
List.of(
root.resolve("designer-target-tools/target/designer-target-tools-0.1.0-SNAPSHOT.jar"),
root.resolve("designer-contracts/target/designer-contracts-0.1.0-SNAPSHOT.jar"),
root.resolve("designer-preview-api/target/designer-preview-api-0.1.0-SNAPSHOT.jar")),
s -> {});
Files.writeString(pid, Long.toString(worker.pid()));
Thread.sleep(60000);
}
}
}
@@ -0,0 +1,47 @@
package de.neitzel.fxdesigner.standalone;
import de.neitzel.fxdesigner.build.*;
import de.neitzel.fxdesigner.contracts.*;
import de.neitzel.fxdesigner.host.*;
import de.neitzel.fxdesigner.workflow.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
public final class WorkflowSmoke {
static int checks;
static void check(boolean value, String message) {
if (!value) throw new AssertionError(message);
checks++;
}
static <T> T await(CompletionStage<T> stage) throws Exception {
return stage.toCompletableFuture().get(120, TimeUnit.SECONDS);
}
public static void main(String[] args) throws Exception {
Path root = Path.of(args[0]), java = Path.of(args[1]), fixture = Path.of(args[2]);
String mode = args[3];
var runtimes =
new ConfiguredRuntimes(
Map.of(
new ConfiguredRuntimes.Key("java21", HostServices.RuntimeRole.BUILD),
new HostServices.RuntimeInstallation(java, 21),
new ConfiguredRuntimes.Key("java21", HostServices.RuntimeRole.TARGET),
new HostServices.RuntimeInstallation(java, 21)));
var tools =
List.of(
root.resolve("designer-target-tools/target/designer-target-tools-0.1.0-SNAPSHOT.jar"),
root.resolve("designer-contracts/target/designer-contracts-0.1.0-SNAPSHOT.jar"),
root.resolve("designer-preview-api/target/designer-preview-api-0.1.0-SNAPSHOT.jar"));
try (var host =
new StandaloneHost(
runtimes,
l -> CompletableFuture.completedFuture(null),
d -> System.out.println(d.message()))) {
checks = new ConnectedWorkflowContract().run(host.services(), root, fixture);
}
System.out.println("PASS connected " + mode + " " + checks);
}
}