Implement retained target sessions, runtime sizing and Component navigation

This commit is contained in:
2026-11-12 14:20:26 +01:00
parent e41b276b13
commit 6e25cf3ac6
71 changed files with 2538 additions and 120 deletions
@@ -0,0 +1,101 @@
package de.neitzel.fxdesigner.target;
import java.util.*;
import javafx.scene.*;
/**
* Target-only attached declaration keys and snapshot-local identities; CSS IDs remain untouched.
*/
public final class Occurrences {
private static final String KEY = "de.neitzel.fxdesigner.declaration";
public static void setKey(Node node, String value) {
node.getProperties().put(KEY, value);
}
public static String getKey(Node node) {
return (String) node.getProperties().get(KEY);
}
private int sequence;
private final IdentityHashMap<Node, Object> models = new IdentityHashMap<>();
private final IdentityHashMap<Node, String> identities = new IdentityHashMap<>();
private final Map<String, Node> nodes = new LinkedHashMap<>();
public String identify(Node node) {
Object model = null;
try {
model = node.getClass().getMethod("getModel").invoke(node);
} catch (NoSuchMethodException ignored) {
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Cannot identify Component Model", failure);
}
if (models.containsKey(node) && models.get(node) != model) {
nodes.remove(identities.remove(node));
}
models.put(node, model);
return identities.computeIfAbsent(
node,
n -> {
String id = "occurrence-" + (sequence++);
nodes.put(id, n);
return id;
});
}
public Node resolve(Parent root, String key) {
var all = new ArrayList<Node>();
collect(root, all);
prune(all);
all.forEach(this::identify);
if (key.isEmpty()) return root;
Node occurrence = nodes.get(key);
if (occurrence != null && all.contains(occurrence)) return occurrence;
var matches = all.stream().filter(n -> key.equals(getKey(n))).toList();
// Compatibility for callers without an instrumented overlay. Ambiguous IDs fail explicitly.
if (matches.isEmpty()) matches = all.stream().filter(n -> key.equals(n.getId())).toList();
if (matches.size() != 1)
throw new IllegalArgumentException("No unique rendered occurrence: " + key);
return matches.getFirst();
}
public Map<String, String> catalog(Parent root) {
var all = new ArrayList<Node>();
collect(root, all);
prune(all);
var data = new LinkedHashMap<String, String>();
int i = 0;
for (Node node : all) {
if (node != root && node.getClass().getName().startsWith("javafx.")) continue;
String p = "occurrence." + (i++) + ".";
data.put(p + "id", identify(node));
data.put(p + "parent", node.getParent() == null ? "" : identify(node.getParent()));
data.put(p + "type", node.getClass().getName());
data.put(p + "key", Objects.toString(getKey(node), ""));
var resource = node.getClass().getResource(node.getClass().getSimpleName() + ".fxml");
data.put(p + "resource", resource == null ? "" : resource.toString());
}
data.put("count", "" + i);
return data;
}
private void prune(List<Node> all) {
var live = Collections.newSetFromMap(new IdentityHashMap<Node, Boolean>());
live.addAll(all);
identities
.keySet()
.removeIf(
n -> {
if (live.contains(n)) return false;
nodes.remove(identities.get(n));
models.remove(n);
return true;
});
}
private static void collect(Node node, List<Node> all) {
if (all.size() >= 10000) throw new IllegalStateException("Occurrence limit exceeded");
all.add(node);
if (node instanceof Parent p) p.getChildrenUnmodifiable().forEach(n -> collect(n, all));
}
}
@@ -20,6 +20,8 @@ public final class TargetPreview implements AutoCloseable {
private PreviewSession<?, ?> session;
private LoadedView view;
private Scene scene;
private final Occurrences occurrences = new Occurrences();
private javafx.stage.Stage interactive;
private boolean closed;
private final CompletableFuture<TargetPreview> ready = new CompletableFuture<>();
private final CompletableFuture<Void> cleanup = new CompletableFuture<>();
@@ -113,7 +115,44 @@ public final class TargetPreview implements AutoCloseable {
PreviewFailure.Code.RESOURCE,
"View resource unavailable: " + d.viewResource() + "; check module resource openness",
null);
view = ViewLoader.load(resource, session.model(), context);
var xml = javax.xml.parsers.DocumentBuilderFactory.newInstance();
xml.setNamespaceAware(true);
xml.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
boolean component;
try (var input = resource.openStream()) {
var element = xml.newDocumentBuilder().parse(input).getDocumentElement();
component =
"http://javafx.com/fxml/1".equals(element.getNamespaceURI())
&& "root".equals(element.getLocalName());
}
if (component) {
String name =
d.viewResource().substring(0, d.viewResource().length() - 5).replace('/', '.');
Class<?> componentType = Class.forName(name, false, loader);
if (!Parent.class.isAssignableFrom(componentType))
throw new IllegalArgumentException("Component root is not a Parent");
byte[] wrapper = ("<" + name + "/>").getBytes(java.nio.charset.StandardCharsets.UTF_8);
var wrapped =
new java.net.URL(
null,
"memory:component",
new java.net.URLStreamHandler() {
protected java.net.URLConnection openConnection(java.net.URL url) {
return new java.net.URLConnection(url) {
public void connect() {}
public java.io.InputStream getInputStream() {
return new java.io.ByteArrayInputStream(wrapper);
}
};
}
});
view = ViewLoader.load(wrapped, session.model(), context);
var model = componentType.getMethod("modelProperty").invoke(view.getRoot());
if (!(model instanceof javafx.beans.property.Property property))
throw new IllegalArgumentException("Component has no writable Model");
property.setValue(session.model());
} else view = ViewLoader.load(resource, session.model(), context);
scene = new Scene(view.getRoot(), 420, 240);
layout(420, 240);
var wait = session.ready();
@@ -164,7 +203,11 @@ public final class TargetPreview implements AutoCloseable {
private void layout(int width, int height) {
view.getRoot().resize(width, height);
view.getRoot().applyCss();
view.getRoot().layout();
for (int iteration = 0; iteration < 8; iteration++) {
view.getRoot().layout();
if (!view.getRoot().isNeedsLayout()) return;
}
throw new IllegalStateException("Target layout did not converge after 8 passes");
}
/** Raw, row-major, straight-alpha RGBA8 matching the wire contract. Must run on FX. */
@@ -182,30 +225,74 @@ public final class TargetPreview implements AutoCloseable {
|| height > 8192
|| (long) width * height * 4 > 16 * 1024 * 1024)
throw new IllegalArgumentException("Image exceeds protocol limits");
layout(width, height);
var node = nodeId.isEmpty() ? view.getRoot() : view.getRoot().lookup("#" + nodeId);
var node = occurrences.resolve(view.getRoot(), nodeId);
if (node == view.getRoot()) layout(width, height);
if (node == null) throw new IllegalArgumentException("Unknown rendered node: " + nodeId);
if (node instanceof javafx.scene.layout.Region region) {
region.resize(width, height);
region.applyCss();
region.layout();
}
var parameters = new SnapshotParameters();
parameters.setFill(Color.TRANSPARENT);
var bounds = node.getBoundsInParent();
parameters.setViewport(new Rectangle2D(bounds.getMinX(), bounds.getMinY(), width, height));
var image = node.snapshot(parameters, new WritableImage(width, height));
byte[] bytes = new byte[width * height * 4];
var pixels = image.getPixelReader();
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++) {
int argb = pixels.getArgb(x, y), i = (y * width + x) * 4;
bytes[i] = (byte) (argb >>> 16);
bytes[i + 1] = (byte) (argb >>> 8);
bytes[i + 2] = (byte) argb;
bytes[i + 3] = (byte) (argb >>> 24);
boolean isolate = node != view.getRoot() && node.isResizable();
boolean managed = node.isManaged();
if (isolate && node.managedProperty().isBound())
throw new IllegalStateException("Cannot isolate allocated size: managed property is bound");
if (isolate) node.setManaged(false);
try {
if (node instanceof javafx.scene.layout.Region region) {
region.resize(width, height);
region.applyCss();
region.layout();
}
return bytes;
var parameters = new SnapshotParameters();
parameters.setFill(Color.TRANSPARENT);
var bounds = node.getBoundsInParent();
parameters.setViewport(new Rectangle2D(bounds.getMinX(), bounds.getMinY(), width, height));
var image = node.snapshot(parameters, new WritableImage(width, height));
byte[] bytes = new byte[width * height * 4];
var pixels = image.getPixelReader();
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++) {
int argb = pixels.getArgb(x, y), i = (y * width + x) * 4;
bytes[i] = (byte) (argb >>> 16);
bytes[i + 1] = (byte) (argb >>> 8);
bytes[i + 2] = (byte) argb;
bytes[i + 3] = (byte) (argb >>> 24);
}
return bytes;
} finally {
if (isolate) node.setManaged(managed);
}
}
public de.neitzel.fxdesigner.protocol.LayoutMetrics measure(String key, int width, int height) {
fx();
var node = occurrences.resolve(view.getRoot(), key);
node.applyCss();
double baseline = node.getBaselineOffset();
if (baseline == Double.NEGATIVE_INFINITY) baseline = height;
return new de.neitzel.fxdesigner.protocol.LayoutMetrics(
occurrences.identify(node),
Math.max(0, node.minWidth(height)),
Math.max(0, node.prefWidth(height)),
Math.max(0, node.maxWidth(height)),
Math.max(0, node.minHeight(width)),
Math.max(0, node.prefHeight(width)),
Math.max(0, node.maxHeight(width)),
Math.max(0, baseline),
node.getContentBias() == null ? "NONE" : node.getContentBias().name());
}
public java.util.Map<String, String> occurrences() {
fx();
return occurrences.catalog(view.getRoot());
}
public void showInteractive(String title, Runnable onClosed) {
fx();
if (interactive == null) {
interactive = new javafx.stage.Stage();
interactive.setScene(scene);
interactive.setOnHidden(e -> onClosed.run());
}
interactive.setTitle(title);
interactive.show();
interactive.toFront();
}
private static void fx() {
@@ -236,6 +323,11 @@ public final class TargetPreview implements AutoCloseable {
cleanup.completeExceptionally(
failure(PreviewFailure.Code.CLEANUP, "Example cleanup failed", e));
} finally {
if (interactive != null) {
interactive.setOnHidden(null);
interactive.close();
interactive = null;
}
session = null;
view = null;
scene = null;
@@ -63,7 +63,7 @@ public final class WorkerMain {
new Version(1, 0),
new Version(1, 0),
new Version(1, 0),
Set.of("discover-v1", "render-rgba-v1"),
Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1"),
Set.of()));
var welcome = FrameCodec.read(socket.getInputStream());
if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1)
@@ -71,6 +71,7 @@ public final class WorkerMain {
socket.setSoTimeout(0);
boolean rendered = false;
String activeExample = null;
Identity captured = null;
while (true) {
var message = FrameCodec.read(socket.getInputStream());
if (!(message instanceof Packet p) || !p.identity().session().equals(session))
@@ -109,7 +110,13 @@ public final class WorkerMain {
throw new IOException("Invalid render metadata");
if (rendered && !data.get("example").equals(activeExample))
throw new IOException("Example changed; start a fresh worker");
if (captured != null
&& (!captured.context().equals(p.identity().context())
|| !captured.snapshot().equals(p.identity().snapshot())
|| !captured.revisions().equals(p.identity().revisions())))
throw new IOException("Snapshot inputs changed; start a fresh worker");
if (!rendered) {
captured = p.identity();
rendered = true;
activeExample = data.get("example");
var descriptor =
@@ -125,7 +132,11 @@ public final class WorkerMain {
});
fx = true;
if (!started.await(10, TimeUnit.SECONDS)) throw new IOException("FX startup timeout");
preview = TargetPreview.open(descriptor, loader, Duration.ofSeconds(20));
preview =
TargetPreview.open(
descriptor,
loader,
Duration.ofSeconds(args.length > 2 ? Integer.parseInt(args[2]) : 20));
}
preview
.ready()
@@ -144,10 +155,7 @@ public final class WorkerMain {
Kind.RESULT,
p.operation(),
p.identity(),
ready.renderNode(
data.get("node"),
p.identity().width(),
p.identity().height())));
response(ready, p, data)));
} catch (Throwable e) {
error(socket, p, e);
}
@@ -164,6 +172,32 @@ public final class WorkerMain {
}
}
private static byte[] response(TargetPreview ready, Packet p, Map<String, String> data)
throws Exception {
return switch (p.operation()) {
case RENDER ->
ready.renderNode(data.get("node"), p.identity().width(), p.identity().height());
case MEASURE ->
PreviewMessages.encode(
ready
.measure(data.get("node"), p.identity().width(), p.identity().height())
.encode());
case OCCURRENCES -> PreviewMessages.encode(ready.occurrences());
case INTERACTIVE -> {
ready.showInteractive(
"Neitzel preview — "
+ data.get("example")
+ " — "
+ p.identity().snapshot()
+ " — drafts "
+ p.identity().revisions(),
WorkerMain::stop);
yield PreviewMessages.encode(Map.of("status", "running"));
}
default -> throw new IOException("Unsupported operation");
};
}
private static void error(Socket socket, Packet p, Throwable error) {
try {
var description = new StringBuilder();