Implement retained target sessions, runtime sizing and Component navigation
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
+116
-24
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user