diff --git a/README.md b/README.md index 2285531..a10ca97 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with minimal standalone and The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits. -The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Full binding/generation editing, structural editing and product packaging remain later work. +The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. Full binding/generation editing, structural editing and product packaging remain later work. Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats and validation rules. @@ -50,7 +50,7 @@ These graphical checks require a macOS desktop session; other platforms are not ## Implementation references - [Initial project contract](docs/initial-project-contract.md): module responsibilities, configuration, snapshots, runtimes, preview sessions and communication. -- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Bootstrap work packages 1–7 are implemented; source-preserving projection is implemented and its [manual drag/resize gate](docs/manual-input-validation.md) passed in both hosts on the recorded baseline. Next: [target-runtime sessions, sizing and navigation](docs/target-runtime-stage-plan.md). +- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Bootstrap work packages 1–7 are implemented; source-preserving projection is implemented and its [manual drag/resize gate](docs/manual-input-validation.md) passed in both hosts on the recorded baseline. Stage 3 now implements [target-runtime sessions, sizing and navigation](docs/target-runtime-sessions.md), with its new human navigation/interactive review still open. - [Decision provenance](docs/provenance.md): workspace planning and captured prototype evidence. - [Third-party notices](THIRD-PARTY-NOTICES.md): Maven wrapper and patched Kit source provenance. diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/config/DesignerConfiguration.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/config/DesignerConfiguration.java index 6a6ac40..0f76b96 100644 --- a/designer-contracts/src/main/java/de/neitzel/fxdesigner/config/DesignerConfiguration.java +++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/config/DesignerConfiguration.java @@ -13,8 +13,26 @@ public record DesignerConfiguration(String schemaVersion, Map c String module, String provider, String defaultExample, - List examples) { + List examples, + int requestTimeoutSeconds, + int readinessTimeoutSeconds) { + public Preview( + String sourceSet, + String module, + String provider, + String defaultExample, + List examples) { + this(sourceSet, module, provider, defaultExample, examples, 30, 20); + } + public Preview { + if (requestTimeoutSeconds == 0) requestTimeoutSeconds = 30; + if (readinessTimeoutSeconds == 0) readinessTimeoutSeconds = 20; + if (requestTimeoutSeconds < 1 + || requestTimeoutSeconds > 300 + || readinessTimeoutSeconds < 1 + || readinessTimeoutSeconds > requestTimeoutSeconds) + throw new IllegalArgumentException("Invalid preview deadlines"); examples = List.copyOf(examples); } } diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/LayoutMetrics.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/LayoutMetrics.java new file mode 100644 index 0000000..b722682 --- /dev/null +++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/LayoutMetrics.java @@ -0,0 +1,65 @@ +package de.neitzel.fxdesigner.protocol; + +import java.util.Map; + +/** Logical target pixels; finite Double.MAX_VALUE represents an unbounded maximum. */ +public record LayoutMetrics( + String occurrence, + double minWidth, + double prefWidth, + double maxWidth, + double minHeight, + double prefHeight, + double maxHeight, + double baseline, + String bias) { + public LayoutMetrics { + if (occurrence == null || occurrence.isBlank() || occurrence.length() > 4096) + throw new IllegalArgumentException("Invalid occurrence"); + for (double value : + new double[] {minWidth, prefWidth, maxWidth, minHeight, prefHeight, maxHeight, baseline}) + if (!Double.isFinite(value) || value < 0) + throw new IllegalArgumentException("Invalid target measurement"); + if (!java.util.Set.of("NONE", "HORIZONTAL", "VERTICAL").contains(bias)) + throw new IllegalArgumentException("Invalid content bias"); + } + + public Map encode() { + return Map.of( + "occurrence", + occurrence, + "minWidth", + "" + minWidth, + "prefWidth", + "" + prefWidth, + "maxWidth", + "" + maxWidth, + "minHeight", + "" + minHeight, + "prefHeight", + "" + prefHeight, + "maxHeight", + "" + maxHeight, + "baseline", + "" + baseline, + "bias", + bias); + } + + public static LayoutMetrics decode(Map m) { + return new LayoutMetrics( + m.get("occurrence"), + number(m, "minWidth"), + number(m, "prefWidth"), + number(m, "maxWidth"), + number(m, "minHeight"), + number(m, "prefHeight"), + number(m, "maxHeight"), + number(m, "baseline"), + m.get("bias")); + } + + private static double number(Map m, String key) { + return Double.parseDouble(m.get(key)); + } +} diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java index 4ce8cea..15787a2 100644 --- a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java +++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/ProtocolSession.java @@ -72,11 +72,23 @@ public final class ProtocolSession implements AutoCloseable { if (hello.preview().major() == 1 && supported.contains("render-rgba-v1") && hello.capabilities().contains("render-rgba-v1")) usable.add(Operation.RENDER); + if (hello.preview().major() == 1 + && supported.contains("layout-v1") + && hello.capabilities().contains("layout-v1")) { + usable.add(Operation.MEASURE); + usable.add(Operation.OCCURRENCES); + } + if (hello.preview().major() == 1 + && supported.contains("interactive-v1") + && hello.capabilities().contains("interactive-v1")) usable.add(Operation.INTERACTIVE); var requiredByEither = new HashSet<>(required); requiredByEither.addAll(hello.required()); if ((requiredByEither.contains("discover-v1") && !usable.contains(Operation.DISCOVER)) || (requiredByEither.contains("render-rgba-v1") && !usable.contains(Operation.RENDER))) throw new IllegalArgumentException("Required capability version incompatible"); + if ((requiredByEither.contains("layout-v1") && !usable.contains(Operation.MEASURE)) + || (requiredByEither.contains("interactive-v1") && !usable.contains(Operation.INTERACTIVE))) + throw new IllegalArgumentException("Required capability version incompatible"); operations = Set.copyOf(usable); state = State.READY; return operations; diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/WireProtocol.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/WireProtocol.java index 74a2212..a6ef231 100644 --- a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/WireProtocol.java +++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/WireProtocol.java @@ -73,7 +73,10 @@ public final class WireProtocol { public enum Operation { DISCOVER, - RENDER + RENDER, + MEASURE, + OCCURRENCES, + INTERACTIVE } public record Identity( @@ -111,14 +114,14 @@ public final class WireProtocol { Objects.requireNonNull(operation); Objects.requireNonNull(identity); Objects.requireNonNull(payload); - boolean render = operation == Operation.RENDER; + boolean render = operation != Operation.DISCOVER; if (render && (identity.width() == 0 || identity.height() == 0 || identity.revisions().isEmpty())) throw new IllegalArgumentException("Rendering needs dimensions and document revisions"); if (!render && (identity.width() != 0 || identity.height() != 0 || !identity.revisions().isEmpty())) throw new IllegalArgumentException("Discovery does not carry render identity"); - boolean image = render && kind == Kind.RESULT; + boolean image = operation == Operation.RENDER && kind == Kind.RESULT; if (image ? payload.length != (long) identity.width() * identity.height() * 4 : payload.length > MAX_METADATA) diff --git a/designer-contracts/src/main/resources/schema/configuration-1.0.json b/designer-contracts/src/main/resources/schema/configuration-1.0.json index 4603e31..03a8771 100644 --- a/designer-contracts/src/main/resources/schema/configuration-1.0.json +++ b/designer-contracts/src/main/resources/schema/configuration-1.0.json @@ -100,6 +100,16 @@ }, "uniqueItems": true, "maxItems": 128 + }, + "requestTimeoutSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 300 + }, + "readinessTimeoutSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 300 } }, "required": [ diff --git a/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/LayoutProtocolTest.java b/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/LayoutProtocolTest.java new file mode 100644 index 0000000..9b9e66b --- /dev/null +++ b/designer-contracts/src/test/java/de/neitzel/fxdesigner/protocol/LayoutProtocolTest.java @@ -0,0 +1,71 @@ +package de.neitzel.fxdesigner.protocol; + +import static de.neitzel.fxdesigner.protocol.WireProtocol.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.net.*; +import java.util.*; +import org.junit.jupiter.api.Test; + +class LayoutProtocolTest { + @Test + void negotiatedMetadataCarriesRenderIdentityButNotPixelLength() throws Exception { + var token = ProtocolSession.newToken(); + try (var session = + new ProtocolSession("s", 42, token, Set.of("layout-v1"), Set.of("layout-v1"))) { + var operations = + session.authenticate( + InetAddress.getLoopbackAddress(), + new Hello( + "s", + 42, + token, + new Version(1, 0), + new Version(1, 0), + new Version(1, 0), + Set.of("layout-v1"), + Set.of())); + assertEquals(Set.of(Operation.MEASURE, Operation.OCCURRENCES), operations); + var metrics = + new LayoutMetrics( + "occurrence-1", 0, 100, Double.MAX_VALUE, 0, 40, Double.MAX_VALUE, 12, "HORIZONTAL"); + var packet = + new Packet( + Kind.RESULT, + Operation.MEASURE, + new Identity("s", "r", "c", "b", Map.of("file", 3L), 100, 50), + PreviewMessages.encode(metrics.encode())); + var out = new java.io.ByteArrayOutputStream(); + FrameCodec.write(out, packet); + var decoded = (Packet) FrameCodec.read(new java.io.ByteArrayInputStream(out.toByteArray())); + assertEquals(metrics, LayoutMetrics.decode(PreviewMessages.decode(decoded.payload()))); + } + } + + @Test + void invalidMeasurementsAndMissingCapabilitiesFail() { + assertThrows( + IllegalArgumentException.class, + () -> new LayoutMetrics("a", 0, Double.NaN, 1, 0, 1, 1, 0, "NONE")); + assertThrows( + IllegalArgumentException.class, () -> new LayoutMetrics("a", 0, 1, 1, 0, 1, 1, -1, "NONE")); + var token = ProtocolSession.newToken(); + try (var session = + new ProtocolSession("s", 42, token, Set.of("layout-v1"), Set.of("layout-v1"))) { + assertThrows( + IllegalArgumentException.class, + () -> + session.authenticate( + InetAddress.getLoopbackAddress(), + new Hello( + "s", + 42, + token, + new Version(1, 0), + new Version(1, 0), + new Version(2, 0), + Set.of("layout-v1"), + Set.of()))); + } + } +} diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/config/ConfigurationFiles.java b/designer-core/src/main/java/de/neitzel/fxdesigner/config/ConfigurationFiles.java index 0c46df2..ca4ee36 100644 --- a/designer-core/src/main/java/de/neitzel/fxdesigner/config/ConfigurationFiles.java +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/config/ConfigurationFiles.java @@ -96,7 +96,9 @@ public final class ConfigurationFiles { p.path("module").asText(null), p.get("provider").asText(), p.path("defaultExample").asText(null), - strings(p.path("examples"))), + strings(p.path("examples")), + p.path("requestTimeoutSeconds").asInt(30), + p.path("readinessTimeoutSeconds").asInt(20)), m == null ? null : new ModulePath( diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/host/HostServices.java b/designer-core/src/main/java/de/neitzel/fxdesigner/host/HostServices.java index 1cff2cc..f31f00f 100644 --- a/designer-core/src/main/java/de/neitzel/fxdesigner/host/HostServices.java +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/host/HostServices.java @@ -31,6 +31,13 @@ public record HostServices( return () -> {}; } + /** + * Saved resources restart affected interactive sessions; draft edits only mark them outdated. + */ + default AutoCloseable onSave(Consumer listener) { + return () -> {}; + } + default CompletionStage> snapshots() { return unsaved(); } @@ -66,6 +73,11 @@ public record HostServices( public interface Navigation { CompletionStage open(Location location); + + /** Editable attached sources whose owning project is known to the host. */ + default CompletionStage> componentSources(String type) { + return java.util.concurrent.CompletableFuture.completedFuture(List.of()); + } } public enum Severity { diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java index 51b7b3d..3bbca24 100644 --- a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java @@ -25,7 +25,9 @@ public final class DesignerWorkflow implements AutoCloseable { int height, byte[] rgba, Map revisions, - String snapshot) { + String snapshot, + LayoutMetrics metrics, + String session) { public Image { rgba = rgba.clone(); revisions = Map.copyOf(revisions); @@ -37,12 +39,26 @@ public final class DesignerWorkflow implements AutoCloseable { } private final HostServices host; + private final Path reactor; private final BuildRefresh build; private final BuildRefresh.Request request; private final List tools; private final Consumer log; private PreviewWorker active; - private long generation; + private final LatestSession rendering = + new LatestSession<>(PreviewWorker::healthy); + private final java.util.concurrent.atomic.AtomicLong renderRequests = + new java.util.concurrent.atomic.AtomicLong(); + private volatile long renderingPid = -1; + + public long renderingPid() { + return renderingPid; + } + + private record RenderKey( + String snapshot, String example, List drafts, boolean last) {} + + private volatile long generation; private boolean closed; public DesignerWorkflow( @@ -54,6 +70,7 @@ public final class DesignerWorkflow implements AutoCloseable { Consumer log) throws Exception { this.host = host; + reactor = configuration.toAbsolutePath().getParent().toRealPath(); this.tools = List.copyOf(tools); this.log = log; request = new BuildRefresh.Request(configuration, context, maven); @@ -68,27 +85,110 @@ public final class DesignerWorkflow implements AutoCloseable { host.lifecycle().own(this); } + public CompletionStage javaSource(String type) { + if (type == null || !type.matches("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*")) + return CompletableFuture.failedFuture(new IllegalArgumentException("Invalid Java type")); + return CompletableFuture.supplyAsync( + () -> { + try (var selected = build.previewLastSuccessful(request.context())) { + var roots = + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(selected.snapshot().details().get("sourceRoots"), String[].class); + var matches = new LinkedHashSet(); + for (String root : roots) { + Path file = Path.of(root).resolve(type.replace('.', '/') + ".java"); + if (Files.isRegularFile(file)) matches.add(file.toRealPath().toUri()); + } + if (matches.size() != 1) + throw new IllegalStateException("No unique Java source for " + type); + return matches.iterator().next(); + } catch (Exception error) { + throw new CompletionException(error); + } + }); + } + + public record ComponentSource(URI document, String origin, boolean editable) { + public String toString() { + return (editable ? "Source: " : "Read-only: ") + origin; + } + } + public CompletionStage componentSource(String type) { + return componentSources(type) + .thenApply( + sources -> { + if (sources.size() != 1) + throw new IllegalStateException("Component has no unique source: " + type); + return sources.getFirst().document(); + }); + } + + public CompletionStage> componentSources(String type) { if (!type.matches("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*")) return CompletableFuture.failedFuture( new IllegalArgumentException("Invalid Component class name")); return CompletableFuture.supplyAsync( () -> { try (var lease = build.previewLastSuccessful(request.context())) { + String resource = type.replace('.', '/') + ".fxml"; var roots = new com.fasterxml.jackson.databind.ObjectMapper() .readValue(lease.snapshot().details().get("sourceRoots"), String[].class); - var matches = new LinkedHashSet(); + var sources = new LinkedHashMap(); for (String root : roots) { - Path path = Path.of(root).resolve(type.replace('.', '/') + ".fxml"); - if (Files.isRegularFile(path)) matches.add(path.toRealPath()); + Path path = Path.of(root).resolve(resource); + if (Files.isRegularFile(path)) { + URI uri = path.toRealPath().toUri(); + sources.put(uri, new ComponentSource(uri, path.toString(), Files.isWritable(path))); + } } - if (matches.size() != 1) + if (!sources.isEmpty()) return List.copyOf(sources.values()); + for (Path artifact : lease.runtime()) { + if (Files.isDirectory(artifact)) continue; + try (var jar = new java.util.zip.ZipFile(artifact.toFile())) { + var entry = jar.getEntry(resource); + if (entry == null) continue; + byte[] bytes; + try (var input = jar.getInputStream(entry)) { + bytes = input.readNBytes(1024 * 1024 + 1); + } + if (bytes.length > 1024 * 1024) + throw new java.io.IOException("Component source exceeds 1 MiB"); + String hash = + HexFormat.of() + .formatHex( + java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); + Path file = + request + .configuration() + .getParent() + .resolve(".neitzel-fx-designer/sources") + .resolve(hash) + .resolve(resource); + Files.createDirectories(file.getParent()); + if (!Files.exists(file)) { + Files.write(file, bytes, StandardOpenOption.CREATE_NEW); + if (!file.toFile().setWritable(false, false)) + throw new java.io.IOException("Cannot protect dependency source"); + } + String origin = + "build " + + lease.snapshot().id() + + " / " + + artifact.getFileName() + + "!/" + + resource; + Files.writeString(file.resolveSibling(file.getFileName() + ".origin"), origin); + sources.put(file.toUri(), new ComponentSource(file.toUri(), origin, false)); + } + } + if (sources.isEmpty()) throw new IllegalStateException( - "Component has no unique editable FXML source in the captured reactor: " + type); - return matches.iterator().next().toUri(); - } catch (Exception e) { - throw new CompletionException(e); + "No Component FXML in captured sources/runtime: " + type); + return List.copyOf(sources.values()); + } catch (Exception error) { + throw new CompletionException(error); } }); } @@ -99,9 +199,14 @@ public final class DesignerWorkflow implements AutoCloseable { } public CompletionStage> discover(boolean lastSuccessful) { + return discover(lastSuccessful, false, List.of()); + } + + private CompletionStage> discover( + boolean lastSuccessful, boolean allProviders, List documents) { return execute( lastSuccessful, - List.of(), + documents, worker -> { var packet = worker.request( @@ -117,7 +222,7 @@ public final class DesignerWorkflow implements AutoCloseable { for (int i = 0; i < count; i++) { String p = "example." + i + "."; String id = data.get(p + "id"); - if (config.preview().provider().equals(data.get(p + "providerClass")) + if ((allProviders || config.preview().provider().equals(data.get(p + "providerClass"))) && (config.preview().examples().isEmpty() || config.preview().examples().contains(id))) result.add( @@ -131,6 +236,91 @@ public final class DesignerWorkflow implements AutoCloseable { }); } + public CompletionStage> examplesFor(boolean last, URI document) { + return host.documents() + .snapshots() + .thenCompose( + drafts -> + discover( + last, + true, + drafts.stream() + .filter( + d -> + d.document().toString().endsWith(".fxml") + && Path.of(d.document()).startsWith(reactor) + && !Path.of(d.document()) + .startsWith(reactor.resolve(".neitzel-fx-designer"))) + .toList())) + .thenApply( + entries -> { + try (var selected = lease(true, List.of())) { + var roots = + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue( + selected.snapshot().details().get("sourceRoots"), String[].class); + Path file = Path.of(document).toRealPath(); + var matching = new HashSet(); + for (String root : roots) { + if (!Files.isDirectory(Path.of(root))) continue; + Path base = Path.of(root).toRealPath(); + if (file.startsWith(base)) + matching.add( + base.relativize(file).toString().replace(java.io.File.separatorChar, '/')); + } + return entries.stream().filter(e -> matching.contains(e.viewResource())).toList(); + } catch (Exception error) { + throw new CompletionException(error); + } + }); + } + + private volatile long interactivePid = -1; + private volatile Runnable interactiveClosed = () -> {}; + + public void onInteractiveClosed(Runnable action) { + interactiveClosed = Objects.requireNonNull(action); + } + + public CompletionStage interactive( + boolean last, String example, List drafts) { + long ownerGeneration; + synchronized (this) { + ownerGeneration = generation; + } + return executeRetained( + last, + example, + drafts, + worker -> { + var revisions = new TreeMap(); + drafts.forEach(d -> revisions.put(d.document().toString(), d.revision())); + if (interactivePid == worker.pid()) return null; + worker.request( + Operation.INTERACTIVE, + revisions, + 640, + 480, + PreviewMessages.encode(Map.of("example", example, "node", "")), + Duration.ofSeconds(worker.context().preview().requestTimeoutSeconds())); + synchronized (this) { + if (generation != ownerGeneration) throw new CancellationException(); + interactivePid = worker.pid(); + } + long pid = worker.pid(); + worker + .exited() + .thenRun( + () -> { + if (generation == ownerGeneration && interactivePid == pid) { + cancel(); + interactiveClosed.run(); + } + }); + return null; + }); + } + public record Size(int width, int height) {} public CompletionStage render( @@ -169,8 +359,9 @@ public final class DesignerWorkflow implements AutoCloseable { if (bytes > 64L * 1024 * 1024) return CompletableFuture.failedFuture( new IllegalArgumentException("Render batch exceeds 64 MiB")); - return execute( + return executeRetained( lastSuccessful, + example, captured, worker -> { var revisions = new TreeMap(); @@ -179,14 +370,24 @@ public final class DesignerWorkflow implements AutoCloseable { for (var entry : requested.entrySet()) { var size = entry.getValue(); String node = entry.getKey(); + var measured = + worker.request( + Operation.MEASURE, + revisions, + size.width(), + size.height(), + PreviewMessages.encode(Map.of("example", example, "node", node)), + Duration.ofSeconds(worker.context().preview().requestTimeoutSeconds())); + var metrics = LayoutMetrics.decode(PreviewMessages.decode(measured.payload())); var result = worker.request( Operation.RENDER, revisions, size.width(), size.height(), - PreviewMessages.encode(Map.of("example", example, "node", node)), - Duration.ofSeconds(30)); + PreviewMessages.encode( + Map.of("example", example, "node", metrics.occurrence())), + Duration.ofSeconds(worker.context().preview().requestTimeoutSeconds())); images.add( new Image( node, @@ -194,7 +395,9 @@ public final class DesignerWorkflow implements AutoCloseable { size.height(), result.payload(), revisions, - result.identity().snapshot())); + result.identity().snapshot(), + metrics, + result.identity().session())); } for (var draft : captured) { try (var doc = host.documents().open(draft.document()).toCompletableFuture().get()) { @@ -206,6 +409,61 @@ public final class DesignerWorkflow implements AutoCloseable { }); } + private SnapshotStore.Lease lease(boolean last, List drafts) throws Exception { + return last + ? build.previewLastSuccessful(request.context()) + : build.previewCurrent( + request, + drafts.stream() + .map(DocumentSnapshot::document) + .collect(java.util.stream.Collectors.toSet())); + } + + private CompletionStage executeRetained( + boolean last, String example, List drafts, Work work) { + long ticket = renderRequests.incrementAndGet(); + return CompletableFuture.supplyAsync( + () -> { + try (var selected = lease(last, drafts)) { + return new RenderKey( + selected.snapshot().id(), + example, + drafts.stream() + .sorted(Comparator.comparing(d -> d.document().toString())) + .toList(), + last); + } catch (Exception e) { + throw new CompletionException(e); + } + }) + .thenCompose( + key -> { + synchronized (this) { + if (closed || renderRequests.get() != ticket) + return CompletableFuture.failedFuture(new CancellationException("Superseded")); + return rendering.submit( + key, + () -> { + var selected = lease(last, drafts); + if (!selected.snapshot().id().equals(key.snapshot())) { + selected.close(); + throw new CancellationException("Build changed during worker startup"); + } + var worker = new PreviewWorker(new DraftOverlay(selected, drafts)); + return worker; + }, + worker -> { + worker.ensureStarted(tools, log); + renderingPid = worker.pid(); + T value = work.run(worker); + if (ticket != renderRequests.get()) + throw new CancellationException("Superseded"); + return value; + }); + } + }); + } + private interface Work { T run(PreviewWorker worker) throws Exception; } @@ -274,6 +532,10 @@ public final class DesignerWorkflow implements AutoCloseable { public synchronized void cancel() { generation++; + renderRequests.incrementAndGet(); + interactivePid = -1; + rendering.cancel(); + renderingPid = -1; var old = active; active = null; if (old != null) Thread.ofVirtual().start(old::close); @@ -283,6 +545,7 @@ public final class DesignerWorkflow implements AutoCloseable { if (closed) return; closed = true; cancel(); + rendering.close(); build.close(); } } diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/LatestSession.java b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/LatestSession.java new file mode 100644 index 0000000..1904887 --- /dev/null +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/LatestSession.java @@ -0,0 +1,157 @@ +package de.neitzel.fxdesigner.workflow; + +import java.util.concurrent.*; + +/** One retained resource, one running operation and one replaceable pending operation. */ +public final class LatestSession implements AutoCloseable { + public interface Factory { + R open() throws Exception; + } + + public interface Work { + T run(R resource) throws Exception; + } + + private record Job( + Object key, + Factory factory, + Work work, + CompletableFuture result, + long revision) {} + + private final java.util.function.Predicate healthy; + + public LatestSession() { + this(value -> true); + } + + public LatestSession(java.util.function.Predicate healthy) { + this.healthy = healthy; + } + + private Job pending; + private R resource; + private Object resourceKey; + private long revision; + private boolean draining, closed; + + public CompletionStage submit(Object key, Factory factory, Work work) { + Job obsolete; + R replaced = null; + boolean start; + var result = new CompletableFuture(); + synchronized (this) { + if (closed) + return CompletableFuture.failedFuture(new IllegalStateException("Session closed")); + obsolete = pending; + if (resource != null && !key.equals(resourceKey)) { + replaced = resource; + resource = null; + resourceKey = null; + } + pending = new Job<>(key, factory, work, result, ++revision); + start = !draining; + draining = true; + } + if (obsolete != null) + obsolete.result.completeExceptionally(new CancellationException("Superseded")); + closeAsync(replaced); + if (start) Thread.ofVirtual().start(this::drain); + return result; + } + + private void drain() { + while (true) { + Job job; + synchronized (this) { + job = pending; + pending = null; + if (job == null) { + draining = false; + return; + } + } + run(job); + } + } + + private void run(Job job) { + R current = null; + try { + synchronized (this) { + if (closed || job.revision != revision) throw new CancellationException(); + if (job.key.equals(resourceKey) && resource != null && healthy.test(resource)) + current = resource; + } + if (current == null) { + disposeResource(); + current = job.factory.open(); + boolean obsolete; + synchronized (this) { + obsolete = closed || job.revision != revision; + if (!obsolete) { + resource = current; + resourceKey = job.key; + } + } + if (obsolete) { + closeResource(current); + throw new CancellationException(); + } + } + T value = job.work.run(current); + synchronized (this) { + if (closed || job.revision != revision) throw new CancellationException("Superseded"); + } + // Never execute client continuations under the resource lock. + job.result.complete(value); + } catch (Throwable failure) { + if (!(failure instanceof CancellationException)) disposeResource(); + job.result.completeExceptionally(failure); + } + } + + private void disposeResource() { + R old; + synchronized (this) { + old = resource; + resource = null; + resourceKey = null; + } + closeResource(old); + } + + private static void closeResource(AutoCloseable value) { + if (value != null) + try { + value.close(); + } catch (Exception ignored) { + } + } + + private static void closeAsync(AutoCloseable value) { + if (value != null) Thread.ofVirtual().start(() -> closeResource(value)); + } + + public void cancel() { + R old; + Job obsolete; + synchronized (this) { + revision++; + obsolete = pending; + pending = null; + old = resource; + resource = null; + resourceKey = null; + } + if (obsolete != null) obsolete.result.completeExceptionally(new CancellationException()); + closeAsync(old); + } + + public void close() { + synchronized (this) { + closed = true; + } + cancel(); + } +} diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java index bb7da96..0df25f7 100644 --- a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java @@ -27,16 +27,38 @@ public final class PreviewWorker implements AutoCloseable { this.overlay = overlay; } + public synchronized boolean healthy() { + return !closed && (process == null || process.isAlive()); + } + public synchronized long pid() { return process == null ? -1 : process.pid(); } + public synchronized CompletionStage exited() { + if (process == null) + return CompletableFuture.failedFuture(new IllegalStateException("Worker not started")); + return process.onExit(); + } + public DesignerConfiguration.Context context() throws Exception { return new com.fasterxml.jackson.databind.ObjectMapper() .readValue( overlay.snapshot().details().get("contextSpec"), DesignerConfiguration.Context.class); } + private boolean started; + + /** Called only by the serialized session executor; close remains independently callable. */ + public void ensureStarted(List tools, Consumer log) throws Exception { + if (!started) { + start(tools, log); + started = true; + } + if (closed || !process.isAlive()) + throw new IOException("Target worker exited; Retry starts a fresh session"); + } + public void start(List tools, Consumer log) throws Exception { var snapshot = overlay.snapshot(); var config = context(); @@ -75,13 +97,14 @@ public final class PreviewWorker implements AutoCloseable { server.setSoTimeout(15000); command.add(Integer.toString(server.getLocalPort())); command.add(id); + command.add(Integer.toString(config.preview().readinessTimeoutSeconds())); process = new ProcessBuilder(command).start(); protocol = new ProtocolSession( id, process.pid(), token, - Set.of("discover-v1", "render-rgba-v1"), + Set.of("discover-v1", "render-rgba-v1", "layout-v1", "interactive-v1"), config.requiredCapabilities()); } drain(process.getInputStream(), log); @@ -184,6 +207,11 @@ public final class PreviewWorker implements AutoCloseable { child.destroyForcibly(); } } + if (child != null && child.isAlive()) child.onExit().thenRun(this::releaseOverlay); + else releaseOverlay(); + } + + private void releaseOverlay() { try { overlay.close(); } catch (IOException ignored) { diff --git a/designer-core/src/test/java/de/neitzel/fxdesigner/config/ConfigurationTest.java b/designer-core/src/test/java/de/neitzel/fxdesigner/config/ConfigurationTest.java index 4468351..f9bd14f 100644 --- a/designer-core/src/test/java/de/neitzel/fxdesigner/config/ConfigurationTest.java +++ b/designer-core/src/test/java/de/neitzel/fxdesigner/config/ConfigurationTest.java @@ -28,6 +28,23 @@ class ConfigurationTest { n.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8), "configuration"); } + @Test + void previewDeadlinesAreBoundedAndOrdered() throws Exception { + var n = valid(); + var preview = (ObjectNode) context(n).get("preview"); + preview.put("requestTimeoutSeconds", 40).put("readinessTimeoutSeconds", 25); + Path file = root.resolve("timeouts.json"); + Files.writeString(file, n.toString()); + assertEquals( + 40, + ConfigurationFiles.load(file).contexts().get("desktop").preview().requestTimeoutSeconds()); + preview.put("requestTimeoutSeconds", 0); + assertThrows(ConfigurationException.class, () -> validate(n)); + preview.put("requestTimeoutSeconds", 10); + Files.writeString(file, n.toString()); + assertThrows(IllegalArgumentException.class, () -> ConfigurationFiles.load(file)); + } + @Test void packagedFixturesValidate() throws Exception { for (String name : List.of("classpath-module", "module-path-test")) { diff --git a/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ComponentSourceTest.java b/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ComponentSourceTest.java new file mode 100644 index 0000000..055ee42 --- /dev/null +++ b/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ComponentSourceTest.java @@ -0,0 +1,65 @@ +package de.neitzel.fxdesigner.workflow; + +import static org.junit.jupiter.api.Assertions.*; + +import de.neitzel.fxdesigner.build.*; +import de.neitzel.fxdesigner.host.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ComponentSourceTest { + @TempDir Path root; + + @Test + void jarSourceIsReadOnlyPersistentAndUnambiguousSourcesArePreferred() throws Exception { + Path jar = root.resolve("components.jar"); + try (var zip = new java.util.zip.ZipOutputStream(Files.newOutputStream(jar))) { + zip.putNextEntry(new java.util.zip.ZipEntry("example/Card.fxml")); + zip.write("".getBytes()); + zip.closeEntry(); + } + Path sources = Files.createDirectories(root.resolve("resources")); + var store = new SnapshotStore(root.resolve(".neitzel-fx-designer/snapshots")); + store.publish( + "test", + Map.of(), + List.of(jar), + Map.of( + "sourceRoots", + new com.fasterxml.jackson.databind.ObjectMapper() + .writeValueAsString(List.of(sources.toString()))), + () -> true); + try (var scope = new SessionScope()) { + var host = + new HostServices( + uri -> CompletableFuture.failedFuture(new UnsupportedOperationException()), + (r, o) -> { + throw new UnsupportedOperationException(); + }, + (n, r) -> CompletableFuture.failedFuture(new UnsupportedOperationException()), + l -> CompletableFuture.completedFuture(null), + d -> {}, + scope); + try (var workflow = + new DesignerWorkflow( + host, root.resolve("neitzel-fx-designer.json"), "test", null, List.of(), s -> {})) { + var result = workflow.componentSources("example.Card").toCompletableFuture().get(); + assertEquals(1, result.size()); + assertFalse(result.getFirst().editable()); + Path file = Path.of(result.getFirst().document()); + assertEquals("", Files.readString(file)); + assertFalse(Files.isWritable(file)); + assertTrue(result.getFirst().origin().contains("components.jar!/example/Card.fxml")); + Path editable = sources.resolve("example/Card.fxml"); + Files.createDirectories(editable.getParent()); + Files.writeString(editable, ""); + assertEquals( + editable.toRealPath().toUri(), + workflow.componentSource("example.Card").toCompletableFuture().get()); + } + } + } +} diff --git a/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ConnectedWorkflowContract.java b/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ConnectedWorkflowContract.java index 562ddf5..0026509 100644 --- a/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ConnectedWorkflowContract.java +++ b/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ConnectedWorkflowContract.java @@ -36,7 +36,7 @@ public final class ConnectedWorkflowContract { var built = await(workflow.build(Map.of()).completion()); check(built.status() == BuildRefresh.Status.READY, "Build failed: " + built.message()); var examples = await(workflow.discover(false)); - check(examples.size() == 9, "Discovery from snapshot"); + check(examples.size() == 11, "Discovery from snapshot"); Path file = fixture.resolve("app/src/main/resources/fixture/components/Person.fxml"), child = fixture.resolve("app/src/main/resources/fixture/components/Card.fxml"); try (var doc = await(host.documents().open(file.toUri())); @@ -44,6 +44,8 @@ public final class ConnectedWorkflowContract { var initial = await(doc.snapshot()); var first = await(workflow.render(false, "standard", "card", 310, 220, List.of(initial))); check(first.rgba().length == 310 * 220 * 4, "Image frame"); + long firstPid = workflow.renderingPid(); + check(first.metrics().prefWidth() >= 0, "Target measurements missing"); var batch = await( workflow.renderImages( @@ -59,6 +61,26 @@ public final class ConnectedWorkflowContract { batch.size() == 2 && batch.stream().allMatch(image -> image.snapshot().equals(first.snapshot())), "Shared parent render batch"); + check(workflow.renderingPid() == firstPid, "Same snapshot restarted worker"); + check( + first + .metrics() + .occurrence() + .equals( + batch.stream() + .filter(i -> i.node().equals("card")) + .findFirst() + .orElseThrow() + .metrics() + .occurrence()), + "Occurrence identity changed"); + var process = ProcessHandle.of(firstPid).orElseThrow(); + process.destroyForcibly(); + process.onExit().get(5, TimeUnit.SECONDS); + var retried = await(workflow.render(false, "standard", "card", 310, 220, List.of(initial))); + check( + workflow.renderingPid() != firstPid && !retried.session().equals(first.session()), + "Crash retry did not replace session"); check( Path.of(await(workflow.componentSource("fixture.components.Card"))) .equals(child.toRealPath()), @@ -76,6 +98,7 @@ public final class ConnectedWorkflowContract { initial.text().length(), initial.text().replace("FXML caption", "Draft caption")))))); var second = await(workflow.render(false, "standard", "card", 310, 220, List.of(changed))); + check(workflow.renderingPid() != firstPid, "Draft change reused worker"); check(!Arrays.equals(first.rgba(), second.rgba()), "Parent draft not rendered"); check(first.snapshot().equals(second.snapshot()), "FXML edit rebuilt Java"); var childInitial = await(component.snapshot()); @@ -169,6 +192,56 @@ public final class ConnectedWorkflowContract { checks++; } } + Path full = fixture.resolve("app/src/main/resources/fixture/components/Full.fxml"); + try (var fullDoc = await(host.documents().open(full.toUri()))) { + var fullDraft = await(fullDoc.snapshot()); + var instrumented = + new DocumentSnapshot( + fullDraft.document(), + fullDraft.revision(), + fullDraft + .text() + .replace( + "id=\"different-css-id\"", + "id=\"different-css-id\"" + + " de.neitzel.fxdesigner.target.Occurrences.key=\"home\""), + fullDraft.writable(), + fullDraft.dirty()); + var fullDrafts = new ArrayList<>(await(host.documents().snapshots())); + var fixtureRoot = fixture.toRealPath(); + fullDrafts.removeIf(d -> !Path.of(d.document()).startsWith(fixtureRoot)); + fullDrafts.removeIf(d -> d.document().equals(fullDraft.document())); + fullDrafts.add(instrumented); + var frames = + await( + workflow.renderImages( + false, + "full", + Map.of( + "home", + new DesignerWorkflow.Size(220, 180), + "visits", + new DesignerWorkflow.Size(420, 240), + "table", + new DesignerWorkflow.Size(420, 250)), + fullDrafts)); + check(frames.size() == 3, "Nested/repeated/Table host render"); + long pid = workflow.renderingPid(); + var resized = await(workflow.render(false, "full", "home", 110, 180, fullDrafts)); + check(workflow.renderingPid() == pid, "Sizing replaced parent Model graph"); + check( + resized.metrics().prefHeight() + >= frames.stream() + .filter(f -> f.node().equals("home")) + .findFirst() + .orElseThrow() + .metrics() + .prefHeight(), + "Host wrapping measurement"); + check( + await(fullDoc.snapshot()).text().equals(fullDraft.text()), + "Derived identity leaked into source"); + } workflow.close(); return checks; } diff --git a/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/LatestSessionTest.java b/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/LatestSessionTest.java new file mode 100644 index 0000000..92ade83 --- /dev/null +++ b/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/LatestSessionTest.java @@ -0,0 +1,81 @@ +package de.neitzel.fxdesigner.workflow; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import org.junit.jupiter.api.Test; + +class LatestSessionTest { + static class Resource implements AutoCloseable { + final AtomicInteger closes; + + Resource(AtomicInteger closes) { + this.closes = closes; + } + + public void close() { + closes.incrementAndGet(); + } + } + + @Test + void retainsReplacesAndReleases() throws Exception { + var opens = new AtomicInteger(); + var closes = new AtomicInteger(); + try (var session = new LatestSession()) { + LatestSession.Factory factory = + () -> { + opens.incrementAndGet(); + return new Resource(closes); + }; + var first = session.submit("a", factory, r -> r).toCompletableFuture().get(); + assertSame(first, session.submit("a", factory, r -> r).toCompletableFuture().get()); + assertNotSame(first, session.submit("b", factory, r -> r).toCompletableFuture().get()); + assertEquals(2, opens.get()); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (closes.get() < 1 && System.nanoTime() < deadline) Thread.sleep(10); + assertEquals(1, closes.get()); + } + long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (closes.get() != 2 && System.nanoTime() < until) Thread.sleep(10); + assertEquals(2, closes.get()); + } + + @Test + void coalescesPendingAndRejectsRunningStaleResult() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var opens = new AtomicInteger(); + var closes = new AtomicInteger(); + try (var session = new LatestSession()) { + LatestSession.Factory factory = + () -> { + opens.incrementAndGet(); + return new Resource(closes); + }; + var first = + session + .submit( + "a", + factory, + r -> { + entered.countDown(); + release.await(); + return 1; + }) + .toCompletableFuture(); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var second = + session.submit("a", factory, r -> fail("Obsolete work ran")).toCompletableFuture(); + var third = session.submit("a", factory, r -> 3).toCompletableFuture(); + release.countDown(); + assertThrows(CancellationException.class, first::join); + assertThrows(CancellationException.class, second::join); + assertEquals(3, third.get(5, TimeUnit.SECONDS)); + assertEquals(1, opens.get()); + } finally { + release.countDown(); + } + } +} diff --git a/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/DesignerFileEditor.java b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/DesignerFileEditor.java index 2015e14..6cf2872 100644 --- a/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/DesignerFileEditor.java +++ b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/DesignerFileEditor.java @@ -36,14 +36,17 @@ public final class DesignerFileEditor extends UserDataHolderBase implements File try { ToolkitGate.check(project, file); Path settings = - Path.of( - System.getProperty( - "neitzel.designer.launch", - Path.of( - project.getBasePath(), - ".neitzel-fx-designer", - "host.properties") - .toString())); + Path.of(project.getBasePath(), ".neitzel-fx-designer", "host.properties"); + String explicit = System.getProperty("neitzel.designer.launch"); + if (explicit != null) { + var configured = HostLaunch.load(Path.of(explicit)); + if (configured + .configuration() + .getParent() + .toRealPath() + .equals(Path.of(project.getBasePath()).toRealPath())) + settings = Path.of(explicit); + } var launch = HostLaunch.load(settings); SwingUtilities.invokeLater( () -> { diff --git a/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijDocuments.java b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijDocuments.java index 97a1cc6..93db184 100644 --- a/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijDocuments.java +++ b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijDocuments.java @@ -26,6 +26,14 @@ public final class IntellijDocuments implements HostServices.Documents, AutoClos private final java.util.concurrent.CopyOnWriteArrayList> changes = new java.util.concurrent.CopyOnWriteArrayList<>(); + private final java.util.List> saves = + new java.util.concurrent.CopyOnWriteArrayList<>(); + + public AutoCloseable onSave(java.util.function.Consumer listener) { + saves.add(listener); + return () -> saves.remove(listener); + } + public AutoCloseable onChange(java.util.function.Consumer listener) { changes.add(listener); return () -> changes.remove(listener); @@ -48,6 +56,18 @@ public final class IntellijDocuments implements HostServices.Documents, AutoClos this.project = Objects.requireNonNull(project); lifetime = this::disposeResources; Disposer.register(project, lifetime); + ApplicationManager.getApplication() + .getMessageBus() + .connect(lifetime) + .subscribe( + com.intellij.openapi.fileEditor.FileDocumentManagerListener.TOPIC, + new com.intellij.openapi.fileEditor.FileDocumentManagerListener() { + public void afterDocumentSaved(Document document) { + var file = FileDocumentManager.getInstance().getFile(document); + if (file != null && file.isInLocalFileSystem()) + saves.forEach(listener -> listener.accept(canonicalUri(file))); + } + }); EditorFactory.getInstance() .getEventMulticaster() .addDocumentListener( diff --git a/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijHost.java b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijHost.java index da6b5de..1041193 100644 --- a/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijHost.java +++ b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/IntellijHost.java @@ -27,31 +27,85 @@ public final class IntellijHost implements AutoCloseable { docs, (request, output) -> scope.own(builds.start(request, output)), runtimes, - location -> { - var result = new CompletableFuture(); - ApplicationManager.getApplication() - .invokeLater( - () -> - WriteIntentReadAction.run( - () -> { - try { - if (project.isDisposed()) - throw new IllegalStateException("Project disposed"); - var file = - LocalFileSystem.getInstance() - .refreshAndFindFileByNioFile( - Path.of(location.document())); - if (file == null) - throw new IllegalArgumentException( - "Navigation file unavailable"); - new OpenFileDescriptor(project, file, location.offset()) - .navigate(true); - result.complete(null); - } catch (Throwable e) { - result.completeExceptionally(e); - } - })); - return result; + new HostServices.Navigation() { + public CompletionStage> componentSources( + String type) { + if (!type.matches("[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*")) + return CompletableFuture.failedFuture( + new IllegalArgumentException("Invalid type")); + return CompletableFuture.supplyAsync( + () -> + com.intellij.openapi.application.ReadAction.compute( + () -> { + var result = new java.util.ArrayList(); + String path = type.replace('.', '/'); + for (var owner : + com.intellij.openapi.project.ProjectManager.getInstance() + .getOpenProjects()) { + var roots = + com.intellij.openapi.roots.ProjectRootManager.getInstance(owner) + .getContentSourceRoots(); + boolean declared = + java.util.Arrays.stream(roots) + .anyMatch( + r -> r.findFileByRelativePath(path + ".java") != null); + if (declared) + for (var root : roots) { + var file = root.findFileByRelativePath(path + ".fxml"); + if (file != null && file.isInLocalFileSystem()) + result.add( + new HostServices.Location( + Path.of(file.getPath()).toUri(), 0)); + } + } + return java.util.List.copyOf(result); + })); + } + + public CompletionStage open(HostServices.Location location) { + var result = new CompletableFuture(); + ApplicationManager.getApplication() + .invokeLater( + () -> + WriteIntentReadAction.run( + () -> { + try { + if (project.isDisposed()) + throw new IllegalStateException("Project disposed"); + var file = + LocalFileSystem.getInstance() + .refreshAndFindFileByNioFile( + Path.of(location.document())); + if (file == null) + throw new IllegalArgumentException( + "Navigation file unavailable"); + Project owner = project; + for (var candidate : + com.intellij.openapi.project.ProjectManager.getInstance() + .getOpenProjects()) + if (!candidate.isDisposed() + && com.intellij.openapi.roots.ProjectFileIndex + .getInstance(candidate) + .isInSourceContent(file)) { + owner = candidate; + break; + } + if ("fxml".equalsIgnoreCase(file.getExtension())) { + var manager = + com.intellij.openapi.fileEditor.FileEditorManager + .getInstance(owner); + manager.openFile(file, true); + manager.setSelectedEditor(file, "neitzel-fx-designer"); + } else + new OpenFileDescriptor(owner, file, location.offset()) + .navigate(true); + result.complete(null); + } catch (Throwable e) { + result.completeExceptionally(e); + } + })); + return result; + } }, diagnostics, scope); diff --git a/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/ConnectedWorkflowTest.java b/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/ConnectedWorkflowTest.java index 12bd597..e35acef 100644 --- a/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/ConnectedWorkflowTest.java +++ b/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/ConnectedWorkflowTest.java @@ -46,7 +46,7 @@ public final class ConnectedWorkflowTest extends BasePlatformTestCase { UIUtil.dispatchAllInvocationEvents(); Thread.sleep(10); } - assertEquals(32, (int) future.get(1, TimeUnit.SECONDS)); + assertEquals(50, (int) future.get(1, TimeUnit.SECONDS)); } } } 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 5fedf6b..c9e5a2a 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 @@ -108,7 +108,21 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { private DocumentSession document; private DocumentSnapshot draft; private FxmlProjection projection; + private final Path reactorRoot; private DesignerWorkflow workflow; + private DesignerWorkflow interactiveWorkflow; + private boolean interactiveRequested, interactiveRestart; + private long interactiveRequest; + private final java.util.concurrent.atomic.AtomicLong draftChanges = + new java.util.concurrent.atomic.AtomicLong(); + private final Label interactiveStatus = new Label(); + private AutoCloseable saves; + + public long interactivePid() { + return interactiveWorkflow.renderingPid(); + } + + private int layoutPasses; private boolean loading, closed, pending, last, canvasCommitQueued; private long canvasGeneration; private final java.util.concurrent.atomic.AtomicLong renderSequence = @@ -139,16 +153,61 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { launch.maven(), launch.tools(), this::log); + interactiveWorkflow = + new DesignerWorkflow( + host, + launch.configuration(), + launch.context(), + launch.maven(), + launch.tools(), + this::log); + interactiveWorkflow.onInteractiveClosed( + () -> + fx( + () -> { + interactiveRequested = false; + interactiveRequest++; + interactiveStatus.setText("Interactive preview closed"); + })); Path reactor = launch.configuration().getParent().toRealPath(); + reactorRoot = reactor; changes = host.documents() .onChange( uri -> { if (Path.of(uri).startsWith(reactor)) { + draftChanges.incrementAndGet(); renderSequence.incrementAndGet(); workflow.cancel(); + fx( + () -> { + if (interactiveRequested) + interactiveStatus.setText( + "Interactive preview uses earlier drafts; rerun or save to" + + " restart"); + }); + fx( + () -> { + layoutPasses = 0; + resize.playFromStart(); + }); } }); + saves = + host.documents() + .onSave( + uri -> { + if (Path.of(uri).startsWith(reactorRoot)) + fx( + () -> { + if (interactiveRequested) { + interactiveRestart = true; + interactiveRequest++; + interactiveWorkflow.cancel(); + } + resize.playFromStart(); + }); + }); var bar = new ToolBar( button("Build and Refresh", () -> build(Map.of())), @@ -160,14 +219,55 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { renderSequence.incrementAndGet(); }), examples, - button("Render drafts", this::render), + button( + "Render drafts", + () -> { + layoutPasses = 0; + render(); + }), + button( + "Interactive preview", + () -> { + interactiveRequested = true; + interactiveRequest++; + interactiveRestart = true; + interactiveWorkflow.cancel(); + render(); + }), + button( + "Stop preview", + () -> { + interactiveRequested = false; + interactiveRequest++; + interactiveRestart = false; + interactiveStatus.setText(""); + interactiveWorkflow.cancel(); + }), button("Preview last successful", () -> discover(true)), button( "Save", () -> withDraft(() -> document.save(draft.revision()).whenComplete(this::updated))), button("Undo", () -> history(false)), button("Redo", () -> history(true)), - button("Open Component", this::openComponent)); + button("Open Component", this::openComponent), + button("Open Row Component", this::openRowComponent), + button( + "Open Java", + () -> { + var selected = kit.editor().getSelection().getHitItem(); + if (selected != null + && projection != null + && projection.navigationTypes().containsKey(selected.getFxId())) + workflow + .javaSource(projection.navigationTypes().get(selected.getFxId())) + .thenCompose( + uri -> host.navigation().open(new HostServices.Location(uri, 0))) + .exceptionally( + error -> { + fx(() -> report(error)); + return null; + }); + })); examples.setConverter( new javafx.util.StringConverter<>() { public String toString(DesignerWorkflow.Example value) { @@ -198,9 +298,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { center.setBottom(properties); output.setEditable(false); output.setPrefRowCount(5); - setTop(bar); + Path origin = file.resolveSibling(file.getFileName() + ".origin"); + setTop( + java.nio.file.Files.isRegularFile(origin) + ? new VBox( + new Label("Read-only origin: " + java.nio.file.Files.readString(origin)), bar) + : bar); setCenter(center); - setBottom(new VBox(status, output)); + setBottom(new VBox(status, interactiveStatus, output)); source .textProperty() .addListener( @@ -340,6 +445,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { private void load(DocumentSnapshot value) { if (closed) return; loading = true; + layoutPasses = 0; canvasCommitQueued = false; canvasGeneration++; renderSequence.incrementAndGet(); @@ -543,7 +649,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { status.setText( useLast ? "Discovering explicitly retained build…" : "Discovering successful build…"); workflow - .discover(useLast) + .examplesFor(useLast, file.toUri()) .whenComplete( (entries, error) -> fx( @@ -601,8 +707,13 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { .filter( d -> d.document().toString().endsWith(".fxml") - && Path.of(d.document()) - .startsWith(launch.configuration().getParent())) + && !Path.of(d.document()) + .startsWith( + launch + .configuration() + .getParent() + .resolve(".neitzel-fx-designer")) + && Path.of(d.document()).startsWith(reactorRoot)) .forEach(d -> drafts.put(d.document(), d)); drafts.put( draft.document(), @@ -640,7 +751,33 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { nodes.put(id, new DesignerWorkflow.Size(width, height)); region.updating(); } - if (nodes.isEmpty()) return; + if (interactiveRequested && interactiveRestart) { + interactiveRestart = false; + long request = ++interactiveRequest, changes = draftChanges.get(); + interactiveStatus.setText("Starting interactive preview of current drafts…"); + interactiveWorkflow + .interactive(last, example, drafts) + .whenComplete( + (value, error) -> + fx( + () -> { + if (!interactiveRequested || request != interactiveRequest) return; + if (error != null) { + interactiveStatus.setText( + "Interactive preview failed; use Interactive preview to retry"); + report(error); + } else + interactiveStatus.setText( + changes == draftChanges.get() + ? "Interactive preview running from captured drafts" + : "Interactive preview uses earlier drafts; rerun or save to" + + " restart"); + })); + } + if (nodes.isEmpty()) { + status.setText("No embedded Components; use Interactive preview for this View"); + return; + } workflow .renderImages(last, example, nodes, drafts) .whenComplete( @@ -649,9 +786,18 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { () -> { if (ticket != renderSequence.get()) return; if (error != null) { + projection + .components() + .keySet() + .forEach( + id -> { + var region = proxy(id); + if (region != null) region.failed(); + }); report(error); return; } + boolean measurementsChanged = false; for (var frame : frames) { int width = frame.width(), height = frame.height(); var image = new WritableImage(width, height); @@ -676,8 +822,25 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { width); images.put(frame.node(), image); var region = proxy(frame.node()); - if (region != null) + if (region != null) { + if (layoutPasses < 8) + measurementsChanged |= region.measurements(frame.metrics()); region.show(image, last ? "Explicit retained build" : "Target runtime"); + } + } + layoutPasses = measurementsChanged ? layoutPasses + 1 : 0; + if (layoutPasses >= 8) { + resize.stop(); + projection + .components() + .keySet() + .forEach( + id -> { + var region = proxy(id); + if (region != null) region.failed(); + }); + status.setText("Target layout did not stabilize; retaining last layout"); + return; } status.setText( "Rendered snapshot " @@ -697,18 +860,62 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { .showAndWait() .ifPresent( id -> { - String type = projection.components().get(id); - workflow - .componentSource(type) - .thenCompose(child -> host.navigation().open(new HostServices.Location(child, 0))) - .exceptionally( - error -> { - fx(() -> report(error)); - return null; - }); + String type = projection.navigationTypes().get(id); + navigateComponent(type); }); } + private void openRowComponent() { + if (projection == null || projection.rowComponents().isEmpty()) { + status.setText("No declared Row Component in this View"); + return; + } + var rows = projection.rowComponents(); + var choice = new ChoiceDialog<>(rows.iterator().next(), rows); + choice.setHeaderText("Open Row Component without changing its layout"); + choice.showAndWait().ifPresent(this::navigateComponent); + } + + private void navigateComponent(String type) { + host.navigation() + .componentSources(type) + .thenCompose( + attached -> { + if (attached.isEmpty()) return workflow.componentSources(type); + return java.util.concurrent.CompletableFuture.completedFuture( + attached.stream() + .map( + location -> + new DesignerWorkflow.ComponentSource( + location.document(), location.document().toString(), true)) + .toList()); + }) + .whenComplete( + (sources, error) -> + fx( + () -> { + if (error != null) { + report(error); + return; + } + DesignerWorkflow.ComponentSource selected; + if (sources.size() == 1) selected = sources.getFirst(); + else { + var choice = new ChoiceDialog<>(sources.getFirst(), sources); + choice.setHeaderText("Choose Component source origin"); + selected = choice.showAndWait().orElse(null); + } + if (selected != null) + host.navigation() + .open(new HostServices.Location(selected.document(), 0)) + .exceptionally( + failure -> { + fx(() -> report(failure)); + return null; + }); + })); + } + public CompletionStage flush() { var result = new CompletableFuture(); flushInto(result, System.nanoTime() + TimeUnit.SECONDS.toNanos(5)); @@ -745,12 +952,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable { closed = true; try { changes.close(); + saves.close(); } catch (Exception ignored) { } poll.stop(); typing.stop(); resize.stop(); workflow.close(); + interactiveWorkflow.close(); if (document != null) document.close(); } } diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/FxmlProjection.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/FxmlProjection.java index c6ebab4..a907d17 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/FxmlProjection.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/FxmlProjection.java @@ -61,6 +61,14 @@ public final class FxmlProjection { private final Map sourceElements = new LinkedHashMap<>(); private final Set synthetic = new HashSet<>(); private Document baseline; + private final Map repeated = new LinkedHashMap<>(); + + public Map navigationTypes() { + var result = new LinkedHashMap<>(components); + result.putAll(repeated); + return Map.copyOf(result); + } + private final Map components = new LinkedHashMap<>(); public FxmlProjection(String text) throws Exception { @@ -88,8 +96,28 @@ public final class FxmlProjection { return Map.copyOf(components); } + public Set rowComponents() { + var result = new LinkedHashSet(); + var nodes = original.getElementsByTagName("*"); + for (int i = 0; i < nodes.getLength(); i++) { + var node = (Element) nodes.item(i); + if (node.hasAttribute("row")) result.add(node.getAttribute("row")); + } + return Set.copyOf(result); + } + public String project() throws Exception { components.clear(); + repeated.clear(); + var declarations = original.getElementsByTagName("*"); + for (int i = 0; i < declarations.getLength(); i++) { + var element = (Element) declarations.item(i); + if ((element.getTagName().equals("Repeat") + || element.getTagName().equals("de.neitzel.fx.view.Repeat")) + && element.hasAttribute("pane") + && element.hasAttribute("component")) + repeated.put(element.getAttribute("pane"), element.getAttribute("component")); + } sourceNodes.clear(); sourceElements.clear(); synthetic.clear(); @@ -161,6 +189,8 @@ public final class FxmlProjection { && tag.contains(".") && Character.isUpperCase(tag.charAt(tag.lastIndexOf('.') + 1)) && !tag.startsWith("javafx.")) componentType = tag; + if (componentType == null && repeated.containsKey(e.getAttributeNS(FX, "id"))) + componentType = tag.contains(".") ? tag : "javafx.scene.layout." + tag; if (componentType != null) { String id = e.getAttributeNS(FX, "id"); components.put(id, componentType); @@ -338,9 +368,12 @@ public final class FxmlProjection { public String previewText() { var edits = new ArrayList(); - for (String id : synthetic) - if (components.containsKey(id)) - edits.addAll(source.attributes(sourceNodes.get(id), Map.of("fx:id", id))); + for (String id : components.keySet()) { + var attributes = new LinkedHashMap(); + attributes.put("de.neitzel.fxdesigner.target.Occurrences.key", id); + if (synthetic.contains(id)) attributes.put("fx:id", id); + edits.addAll(source.attributes(sourceNodes.get(id), attributes)); + } if (!edits.isEmpty() && !original.getDocumentElement().hasAttribute("xmlns:fx")) edits.addAll(source.attributes(0, Map.of("xmlns:fx", FX))); return source.apply(edits); diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/SnapshotRegion.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/SnapshotRegion.java index fd24a48..e32879b 100644 --- a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/SnapshotRegion.java +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/SnapshotRegion.java @@ -23,6 +23,56 @@ public final class SnapshotRegion extends Region { return alignment; } + private de.neitzel.fxdesigner.protocol.LayoutMetrics metrics; + + public boolean measurements(de.neitzel.fxdesigner.protocol.LayoutMetrics value) { + boolean changed = + metrics == null + || Math.abs(metrics.prefWidth() - value.prefWidth()) > .5 + || Math.abs(metrics.prefHeight() - value.prefHeight()) > .5 + || Math.abs(metrics.minWidth() - value.minWidth()) > .5 + || Math.abs(metrics.minHeight() - value.minHeight()) > .5 + || Math.abs(metrics.maxWidth() - value.maxWidth()) > .5 + || Math.abs(metrics.maxHeight() - value.maxHeight()) > .5 + || Math.abs(metrics.baseline() - value.baseline()) > .5 + || !metrics.bias().equals(value.bias()); + metrics = value; + if (changed) requestLayout(); + return changed; + } + + @Override + public javafx.geometry.Orientation getContentBias() { + return metrics == null || metrics.bias().equals("NONE") + ? null + : javafx.geometry.Orientation.valueOf(metrics.bias()); + } + + @Override + public double getBaselineOffset() { + return metrics == null ? super.getBaselineOffset() : metrics.baseline(); + } + + protected double computeMinWidth(double height) { + return metrics == null ? 0 : metrics.minWidth(); + } + + protected double computeMinHeight(double width) { + return metrics == null ? 0 : metrics.minHeight(); + } + + protected double computeMaxWidth(double height) { + return metrics == null ? Double.MAX_VALUE : metrics.maxWidth(); + } + + protected double computeMaxHeight(double width) { + return metrics == null ? Double.MAX_VALUE : metrics.maxHeight(); + } + + public void failed() { + status.setText("Preview failed — retained image is stale; Retry with Render drafts"); + } + private final ImageView pixels = new ImageView(); private final Label status = new Label("Render preview"); @@ -70,10 +120,10 @@ public final class SnapshotRegion extends Region { } protected double computePrefWidth(double height) { - return 300; + return metrics == null ? 300 : metrics.prefWidth(); } protected double computePrefHeight(double width) { - return 220; + return metrics == null ? 220 : metrics.prefHeight(); } } 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 ab8ac50..9c83abc 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 @@ -406,6 +406,53 @@ public final class ProjectionHostChecks { .get() .text() .equals(root.replace("root text", "root changed") + "")); + var fullFile = + java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document()) + .resolveSibling("Full.fxml"); + String full = java.nio.file.Files.readString(fullFile); + set(document, full); + await(() -> fx(() -> source(pane).getText().equals(full) && object(pane, "home") != null)); + click(pane, "Render drafts"); + await( + () -> + fx( + () -> + java.util.List.of("home", "visits", "table").stream() + .allMatch( + id -> + object(pane, id).getSceneGraphObject() instanceof SnapshotRegion r + && r.image() != null + && r.statusText().equals("Target runtime")))); + check( + document.snapshot().toCompletableFuture().get().text().equals(full), + "Runtime sizing changed source"); + document + .save(document.snapshot().toCompletableFuture().get().revision()) + .toCompletableFuture() + .get(); + click(pane, "Interactive preview"); + await( + () -> + pane.interactivePid() > 0 + && ProcessHandle.of(pane.interactivePid()) + .map(ProcessHandle::isAlive) + .orElse(false)); + long interactivePid = pane.interactivePid(); + String editedFull = full + ""; + set(document, editedFull); + await(() -> fx(() -> source(pane).getText().equals(editedFull))); + Thread.sleep(500); + check(pane.interactivePid() == interactivePid, "Unsaved edit restarted interactive sample"); + document + .save(document.snapshot().toCompletableFuture().get().revision()) + .toCompletableFuture() + .get(); + await(() -> pane.interactivePid() > 0 && pane.interactivePid() != interactivePid); + await(() -> ProcessHandle.of(interactivePid).map(p -> !p.isAlive()).orElse(true)); + long restarted = pane.interactivePid(); + click(pane, "Stop preview"); + await(() -> ProcessHandle.of(restarted).map(p -> !p.isAlive()).orElse(true)); + set(document, original); await(() -> fx(() -> source(pane).getText().equals(original) && object(pane, "card") != null)); document diff --git a/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/DesignerApplication.java b/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/DesignerApplication.java index e2dffce..509f90f 100644 --- a/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/DesignerApplication.java +++ b/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/DesignerApplication.java @@ -13,6 +13,7 @@ public final class DesignerApplication extends Application { private HostLaunch launch; private StandaloneHost host; private int windows; + private final java.util.Map editors = new java.util.HashMap<>(); @Override public void start(Stage stage) throws Exception { @@ -75,6 +76,14 @@ public final class DesignerApplication extends Application { } private void open(Stage stage, Path file) throws Exception { + Path identity = file.toRealPath(); + var existing = editors.get(identity); + if (existing != null) { + existing.show(); + existing.toFront(); + existing.requestFocus(); + return; + } if (host == null) host = new StandaloneHost( @@ -97,6 +106,7 @@ public final class DesignerApplication extends Application { stage.setTitle("Neitzel FX Designer — " + file.getFileName()); stage.setScene(new Scene(pane, 1300, 850)); windows++; + editors.put(identity, stage); boolean[] closing = {false}; stage.setOnCloseRequest( event -> { @@ -181,6 +191,7 @@ public final class DesignerApplication extends Application { }); stage.setOnHidden( e -> { + editors.remove(identity, stage); pane.close(); if (--windows == 0) host.close(); }); diff --git a/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/FileDocuments.java b/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/FileDocuments.java index fd5e37b..7f1bb11 100644 --- a/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/FileDocuments.java +++ b/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/FileDocuments.java @@ -17,6 +17,14 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl private final java.util.concurrent.CopyOnWriteArrayList> changes = new java.util.concurrent.CopyOnWriteArrayList<>(); + private final java.util.List> saves = + new java.util.concurrent.CopyOnWriteArrayList<>(); + + public AutoCloseable onSave(java.util.function.Consumer listener) { + saves.add(listener); + return () -> saves.remove(listener); + } + public AutoCloseable onChange(java.util.function.Consumer listener) { changes.add(listener); return () -> changes.remove(listener); @@ -186,6 +194,7 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl Files.deleteIfExists(temp); } s.saved = s.text; + saves.forEach(listener -> listener.accept(s.file.toUri())); return FileDocuments.snapshot(s); }); } diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/Occurrences.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/Occurrences.java new file mode 100644 index 0000000..9e4e2f4 --- /dev/null +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/Occurrences.java @@ -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 models = new IdentityHashMap<>(); + private final IdentityHashMap identities = new IdentityHashMap<>(); + private final Map 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(); + 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 catalog(Parent root) { + var all = new ArrayList(); + collect(root, all); + prune(all); + var data = new LinkedHashMap(); + 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 all) { + var live = Collections.newSetFromMap(new IdentityHashMap()); + 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 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)); + } +} diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TargetPreview.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TargetPreview.java index ea8e804..09aeae5 100644 --- a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TargetPreview.java +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/TargetPreview.java @@ -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 ready = new CompletableFuture<>(); private final CompletableFuture 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 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; diff --git a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java index 3a508fc..d747b36 100644 --- a/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java @@ -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 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(); diff --git a/docs/configuration-and-protocol.md b/docs/configuration-and-protocol.md index e6f2fc8..466f908 100644 --- a/docs/configuration-and-protocol.md +++ b/docs/configuration-and-protocol.md @@ -100,3 +100,7 @@ The standard bootstrap command runs the configuration/schema/context/runtime tes Verified on 2026-11-12: **33 tests passed** (30 Maven, three IntelliJ), with zero failures, errors or skipped tests. The full clean bootstrap check also passed Java 21 target linkage, bytecode/dependency checks, plugin packaging and documentation validation. No socket/worker or rendered UI qualification is implied. Scoped Maven build planning and immutable successful-build snapshots are now [implemented](build-and-refresh.md). Real provider discovery, metadata payload schemas, target rendering, socket integration, setup UI, OS qualification and the first connected two-host workflow retain their later gates. + +## Stage-3 additive preview capabilities + +The [runtime session implementation](target-runtime-sessions.md) adds negotiated `layout-v1` (MEASURE/OCCURRENCES) and `interactive-v1` operations without changing existing operation ordinals. The optional Preview configuration fields `requestTimeoutSeconds` and `readinessTimeoutSeconds` are schema-validated and default to 30 and 20 seconds. Readiness cannot exceed the request deadline. Measurement metadata and images retain full session/build/document/size correlation. diff --git a/docs/first-connected-workflow.md b/docs/first-connected-workflow.md index d4ffd4e..82ebc6e 100644 --- a/docs/first-connected-workflow.md +++ b/docs/first-connected-workflow.md @@ -4,6 +4,8 @@ Bootstrap work package 7 connects the existing build/snapshot services to real t Later implementation: [source-preserving projection](source-preserving-projection.md) replaces the original serialization and extends anonymous/nested Component and fx:root editing. Historical qualification and initial limitations below describe work package 7. +Later implementation: [target-runtime sessions](target-runtime-sessions.md) retains workers across sizing requests and adds measured layout, independent interactive preview and richer navigation. The per-batch process lifetime described below is the historical bootstrap behavior. + ## Flow and ownership ```mermaid diff --git a/docs/repository-bootstrap-plan.md b/docs/repository-bootstrap-plan.md index 38e7328..99a87d4 100644 --- a/docs/repository-bootstrap-plan.md +++ b/docs/repository-bootstrap-plan.md @@ -95,3 +95,7 @@ Do not label these packages as additional unresolved Wayfinder decisions: the ar ## After bootstrap: source-preserving projection Roadmap stage 2 now has source-range transactions, broader Component projection and shared live checks in both hosts. See [implementation and qualification boundaries](source-preserving-projection.md). The [human standalone and IntelliJ review](manual-input-validation.md) now closes the physical drag/drop and resize gate on the recorded macOS baseline, including the corner-resize correction at `b9d59e1`. Broader platform/input coverage remains separate. Continue with the [stage-3 implementation plan](target-runtime-stage-plan.md): retained target sessions, occurrence identity and sizing, shared canvas refresh, Component/Row navigation, and separate interactive previews. + +## Target-runtime sessions, sizing and navigation + +Stage 3 now has retained workers, scoped occurrences and target measurements, shared sizing/refresh, independent interactive preview, and Component/Row source navigation. See [implementation and qualification](target-runtime-sessions.md). Keep the new human navigation/interactive review open until its workflows are observed; the completed stage-2 drag/resize review does not automatically qualify these actions. diff --git a/docs/source-preserving-projection.md b/docs/source-preserving-projection.md index 15e19ba..5e691d9 100644 --- a/docs/source-preserving-projection.md +++ b/docs/source-preserving-projection.md @@ -69,4 +69,4 @@ The user subsequently passed the physical corner retest for both Components in b 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. -Qualification remains scoped to the recorded macOS arm64 / IntelliJ 263.7307.200 / JavaFX and Kit 26 host baseline and Java 21 / JavaFX 21.0.7 application fixtures. Product packaging, a broader platform matrix, lossless structural editing and wider input qualification are not implied. The stage-2 physical input gate is closed on the tested baseline. Next: [target-runtime sessions, sizing and navigation](target-runtime-stage-plan.md). +Qualification remains scoped to the recorded macOS arm64 / IntelliJ 263.7307.200 / JavaFX and Kit 26 host baseline and Java 21 / JavaFX 21.0.7 application fixtures. Product packaging, a broader platform matrix, lossless structural editing and wider input qualification are not implied. The stage-2 physical input gate is closed on the tested baseline. The following [target-runtime stage](target-runtime-sessions.md) implements retained sessions, sizing and navigation; its new actions have a separate human review. diff --git a/docs/target-runtime-sessions.md b/docs/target-runtime-sessions.md new file mode 100644 index 0000000..211082c --- /dev/null +++ b/docs/target-runtime-sessions.md @@ -0,0 +1,61 @@ +# Target-runtime sessions, sizing and Component navigation + +Stage 3 extends the shared workflow used by standalone and IntelliJ. The [implementation sequence](target-runtime-stage-plan.md) records the accepted scope. Qualification below distinguishes automated checks from the remaining human review of the new actions. + +## Session and request ownership + +A retained canvas session owns one target JVM, private draft overlay and lease on one successful build. Its key contains build identity, selected example, the exact captured document set (including content/revisions), and explicit retained-build selection. Allocated dimensions are request inputs, not a reason to construct another Model graph. A changed key replaces the worker. A crashed worker is replaced on retry. + +`LatestSession` runs one operation at a time and keeps one replaceable pending operation. Superseded futures fail with cancellation; host generation and document checks reject obsolete results. Replacement and cancellation close transport independently of the FX thread. Client continuations run outside the session lock. Idle retained workers have no whole-session deadline; startup, readiness and individual requests remain bounded. A snapshot lease is released only after the process has actually exited, including delayed forced termination. + +Configuration 1.0 accepts optional `preview.requestTimeoutSeconds` (default 30) and `preview.readinessTimeoutSeconds` (default 20), each from 1 to 300; readiness cannot exceed the request deadline. These values do not select a different JVM or weaken the owner-lifetime channel. Discovery still reads metadata without constructing application objects. + +Interactive preview uses another workflow and another JVM/Model graph. **Interactive preview** captures current drafts and opens the target window; **Stop preview** terminates it. Its title identifies example, build and captured revisions. Closing the target window releases that session and stops automatic reopening. Unsaved document edits mark an interactive preview outdated while preserving its sample state. Explicit rerun or a resource save restarts it with fresh sample data. Canvas previews continue to refresh from drafts. No application class is loaded into either host. + +## Identity, measurement and pixels + +The optional negotiated `layout-v1` capability adds MEASURE and OCCURRENCES operations; `interactive-v1` adds INTERACTIVE. Existing operation ordinals and raw RGBA rendering remain compatible. Metadata operations carry session/build/document/size correlation without claiming a pixel payload. Required incompatible capabilities fail negotiation. + +Private parent overlays attach target-only declaration keys to projected nodes; authoritative FXML and CSS IDs remain unchanged. The worker gives live nodes opaque session-local occurrence IDs and reports their parent scope, declared key, actual type and Component resource origin. Nested and repeated instances have separate identities. Detached nodes are pruned and an instance whose Model changes receives a new identity. Legacy callers may use a CSS ID only when it identifies exactly one node. Treat occurrence IDs together with their worker session, never as durable project IDs. + +Measurements return min/pref/max dimensions, baseline and content bias. Width-dependent preferred height and height-dependent preferred width are queried at the requested allocation. Finite `Double.MAX_VALUE` means an unbounded maximum; invalid/non-finite measurements fail validation. SnapshotRegion uses these values only for computed layout, not saved attributes. + +The Kit parent and explicit constraints allocate size. Target capture temporarily makes the selected resizable node unmanaged so a parent layout during snapshot cannot overwrite that allocation; it restores managed state afterward and does not resize the entire parent for each child. A bound managed property currently produces an explicit diagnostic instead of silently capturing at the wrong size. Retained images remain visible during asynchronous updates. Target layout passes and host measurement/allocation passes are bounded at eight; failure retains the previous image with a diagnostic. A parent load failure cannot publish a partially successful image batch. + +Repeat containers are represented by opaque target-rendered proxies, like Components and Tables; their repeated children are selectable as a whole in the parent. Nested internals are edited in their own Component document. Instance scopes and runtime metadata do not yet supply stage 4's binding authoring UI. + +## Draft refresh and navigation + +Parent and open FXML drafts are captured together. Canonical path handling covers macOS aliases, and a reactor filter prevents another project's/context's documents from entering the overlay. Read-only dependency cache documents are excluded from overlays. Invalidation is currently conservative within a reactor: it may refresh more open previews than a future exact dependency graph would require. + +**Open Component** opens a separate editor, reusing an existing standalone window or IntelliJ designer tab. Repeat-container navigation resolves its declared repeated Component. **Open Row Component** follows the Table's explicit Row declaration without changing its layout. **Open Java** navigates the selected Component's source through the host. The parent keeps its draft and undo history. + +Example selection is filtered by the opened View resource. A standalone `fx:root` Component loads through a generated private wrapper, then receives its own example Model through its model property; it does not borrow the parent's default example. Fixtures supply independent Card, AddressForm, NoteForm and VisitRow examples. + +Reactor sources are editable. IntelliJ can also find source/resource pairs in open source-owning projects and opens them in that project; each project uses its own launch configuration. Multiple candidates prompt for an origin. JAR-only FXML is copied into a content-addressed read-only source cache with an origin label. It is not a writable application resource or a snapshot lease-dependent temporary editor file. Arbitrary unattached source archives and remote projects are not inferred to be editable projects. + +## Regression coverage + +- Retained resource reuse/replacement, latest pending request, stale result rejection, bounded measurement metadata, capability negotiation, deadline configuration and read-only JAR navigation. +- Real worker PID reuse for sizing, fresh worker on draft changes, process-crash retry, captured child overlays, cancellation, and owner death. +- Classpath/module-path target fixtures: Person.home → AddressForm → NoteForm, repeated visits, Table row occurrences, shared Models, independent Component examples, empty/missing values, wrapping measurements, actual allocated capture dimensions, Controller actions, conversion failure/recovery, Table selection/sorting/filtering and interactive window creation. +- Existing readiness, cleanup, invalid attributes, module access, immutable builds and source-preservation scenarios remain in the suites. +- Shared live Kit scenarios render AddressForm, Repeat and Table proxies in standalone and the registered IntelliJ designer while asserting that runtime sizing does not alter source. + +The qualification runs passed 64 Maven tests and 7 IntelliJ adapter tests; 32 target processes executed 402 fixture checks. Connected workflow checks cover both hosts with classpath and module-path applications (100 assertions), including native shared Kit rendering and interactive draft/save lifecycle. Registered IntelliJ checks additionally verify the actual designer extension and exclusive JavaFX 26 native libraries. + +Run the bootstrap and registered IDE commands in [README](../README.md). A successful automated run does not close the human navigation/interactive review: verify parent → Component → nested Component, repeated open/reuse, independent examples, interactive editing, save-triggered restart, and closing/reopening preview in both hosts. The completed stage-2 mouse review remains valid for its artifact; input changes should receive focused retesting. + +## Next human review (both hosts) + +1. Open `Full.fxml`, Build and Refresh, and select its `full` example. Select AddressForm and use Open Component. Confirm a separate editor opens and repeated opening reuses it, with the parent draft intact. +2. Build and Refresh the child; verify its independent example, then open nested NoteForm. Return to the parent and check selection/editability and Undo/Redo. +3. Open the repeated visit Component, then explicitly Open Row Component on the Table. Confirm AddressForm for repeated visits and VisitRow for Table rows, with no parent layout changes. +4. Start Interactive preview. Edit the street and exercise the action button and Table. Make an unsaved designer edit: the running sample state should remain and show outdated status. Save: preview should restart with fresh sample data and the saved change. +5. Close the interactive window, rerun it, stop it, and close the owning editor. Check that windows close and can be opened again. Repeat with the module-path fixture and IntelliJ host. + +These checks are pending; automated window/control checks do not count as human acceptance. + +## Boundaries + +Qualification remains the recorded macOS arm64 / OpenJDK 25 / JavaFX and Kit 26 / IntelliJ 263.7307.200 host baseline and Java 21 / JavaFX 21.0.7 target fixtures. New protocol fields and preview behavior are development contracts, not a frozen public release. Structural authoring, binding/generation panels, production host handoff, broader platform coverage and packaging retain their later gates. VoiceOver remains outside scope. diff --git a/docs/target-runtime-stage-plan.md b/docs/target-runtime-stage-plan.md index ff33dd4..8ee8411 100644 --- a/docs/target-runtime-stage-plan.md +++ b/docs/target-runtime-stage-plan.md @@ -1,8 +1,8 @@ # Target-runtime sessions, sizing and Component navigation -Status: implementation plan for roadmap stage 3; **not implemented or qualified by this document**. Stage 2's [manual input gate](manual-input-validation.md) is closed on its recorded baseline. This work implements the accepted preview and host contracts; it does not reopen them as Wayfinder decisions. +Status: stage-3 implementation and automated qualification are described in the [runtime implementation report](target-runtime-sessions.md). The new navigation/interactive human review remains a separate gate. This document retains the implementation sequence and acceptance criteria. Stage 2's [manual input gate](manual-input-validation.md) is closed on its recorded baseline. This work implements the accepted preview and host contracts; it does not reopen them as Wayfinder decisions. -## Current implementation and gaps +## Starting implementation and gaps (before this stage) | Area | Existing code | Work required | | --- | --- | --- | diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/AddressForm.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/AddressForm.java new file mode 100644 index 0000000..95c5a8e --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/AddressForm.java @@ -0,0 +1,25 @@ +package fixture.components; + +@jakarta.inject.Singleton +public class AddressForm extends javafx.scene.layout.VBox { + private final javafx.beans.property.ObjectProperty model = + new javafx.beans.property.SimpleObjectProperty<>(); + + public javafx.beans.property.ObjectProperty modelProperty() { + return model; + } + + public Object getModel() { + return model.get(); + } + + public void setModel(Object value) { + model.set(value); + } + + @javafx.fxml.FXML private NoteForm nested; + + public void initialize() { + nested.modelProperty().bind(model); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/NoteForm.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/NoteForm.java new file mode 100644 index 0000000..eedbf0a --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/NoteForm.java @@ -0,0 +1,19 @@ +package fixture.components; + +@jakarta.inject.Singleton +public class NoteForm extends javafx.scene.layout.VBox { + private final javafx.beans.property.ObjectProperty model = + new javafx.beans.property.SimpleObjectProperty<>(); + + public javafx.beans.property.ObjectProperty modelProperty() { + return model; + } + + public Object getModel() { + return model.get(); + } + + public void setModel(Object value) { + model.set(value); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java index 2038917..bfaf0ce 100644 --- a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/PersonController.java @@ -15,6 +15,10 @@ public class PersonController implements de.neitzel.fx.view.HasViewModel { received = vm; } + public void action() { + ((javafx.beans.property.Property) received.property("name")).setValue("Controller action"); + } + public String greeting() { if (received == null) throw new IllegalStateException("No ViewModel before bindings"); return greeting.text(); diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/RankConverter.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/RankConverter.java new file mode 100644 index 0000000..91ed6fd --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/RankConverter.java @@ -0,0 +1,12 @@ +package fixture.components; + +@jakarta.inject.Singleton +public class RankConverter implements de.neitzel.fx.view.Converter { + public Object toControlValue(Object value) { + return String.valueOf(value); + } + + public Object toAttributeValue(Object value) { + return Integer.valueOf((String) value); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/UnstableLayout.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/UnstableLayout.java new file mode 100644 index 0000000..d1a67ca --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/UnstableLayout.java @@ -0,0 +1,7 @@ +package fixture.components; + +public class UnstableLayout extends javafx.scene.layout.VBox { + protected void layoutChildren() { + requestLayout(); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/VisitRow.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/VisitRow.java new file mode 100644 index 0000000..def5a6d --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/components/VisitRow.java @@ -0,0 +1,19 @@ +package fixture.components; + +@jakarta.inject.Singleton +public class VisitRow extends javafx.scene.layout.HBox { + private final javafx.beans.property.ObjectProperty model = + new javafx.beans.property.SimpleObjectProperty<>(); + + public javafx.beans.property.ObjectProperty modelProperty() { + return model; + } + + public Object getModel() { + return model.get(); + } + + public void setModel(Object value) { + model.set(value); + } +} diff --git a/integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Person.java b/integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Person.java index dc845a4..3101eab 100644 --- a/integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Person.java +++ b/integration-fixtures/preview/classpath/app/src/main/java/fixture/model/Person.java @@ -1,6 +1,26 @@ package fixture.model; public class Person { + private java.util.List
visits = new java.util.ArrayList<>(); + + public java.util.List
getVisits() { + return visits; + } + + public void setVisits(java.util.List
value) { + visits = value; + } + + private int rank = 7; + + public int getRank() { + return rank; + } + + public void setRank(int value) { + rank = value; + } + private String name = "Ada"; private Address home = new Address(); diff --git a/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/AddressForm.fxml b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/AddressForm.fxml new file mode 100644 index 0000000..6244407 --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/AddressForm.fxml @@ -0,0 +1,2 @@ + + diff --git a/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Full.fxml b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Full.fxml new file mode 100644 index 0000000..829b4fe --- /dev/null +++ b/integration-fixtures/preview/classpath/app/src/main/resources/fixture/components/Full.fxml @@ -0,0 +1,7 @@ + + + + + +
+