diff --git a/README.md b/README.md index dd1a1e0..a4855d7 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ # Neitzel FX Designer -Shared Scene Builder Kit designer for NeitzelLib FX, with planned standalone and IntelliJ hosts. +Shared Scene Builder Kit designer for NeitzelLib FX, with minimal standalone and IntelliJ hosts. ## Current state -The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Document transactions, native undo, build execution/cancellation, explicit runtime mappings and lifecycle services have initial implementations and tests. The shared UI exposes a Kit construction seam. There is **no runnable designer application or registered IntelliJ designer editor yet**. See [module and host boundaries](docs/module-host-boundaries.md) for the implemented scope and limitations. +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. -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, limits and the remaining Maven/worker integration. +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 minimal UI supports scalar/layout projection; 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. The shared [Build and Refresh workflow](docs/build-and-refresh.md) now builds isolated reactor copies and publishes immutable, leased snapshots, with explicit save approval and last-successful-build selection. The [preview API and target adapter](docs/preview-target-fixtures.md) are validated with real classpath and named-module NeitzelLib applications, including bindings, rendering, readiness and cleanup. Each preview session requires a fresh target JVM. -The Component builder fix is integrated in NeitzelLib main. A [verified local development snapshot](docs/runtime/neitzellib-baseline.md) is recorded with source/artifact identity; a corrected released version is still unestablished. NeitzelLib sources and prototype implementations are not copied here. +The Component builder fix is integrated in NeitzelLib main. A [verified local development snapshot](docs/runtime/neitzellib-baseline.md) is recorded with source/artifact identity; a corrected released version is still unestablished. NeitzelLib sources are not copied here; the narrow Kit text-selection patch has explicit provenance and regression coverage. ## Validate the foundation @@ -22,16 +24,35 @@ Requirements: JDK 25 for builds, JDK 21 for target linkage validation, Python 3. python3 scripts/check-bootstrap.py --idea-home "$IDEA_HOME" --target-java-home "$TARGET_JAVA_HOME" ``` -On macOS `IDEA_HOME` points to the IDE application's `Contents` directory. The current SDK check uses its macOS product-info layout; other platforms are not qualified. The command checks runtime artifact hashes, clean-builds/installs the Maven modules, tests the adapters, checks bytecode baselines, runs target linkage under the supplied target JDK, builds the plugin ZIP, runs the real Maven snapshot and target-preview fixtures, and validates documentation. It does not install or publish NeitzelLib. +On macOS `IDEA_HOME` points to the IDE application's `Contents` directory. The current SDK check uses its macOS product-info layout; other platforms are not qualified. The command checks runtime artifact hashes, clean-builds/installs the Maven modules, tests the adapters, checks bytecode baselines, runs target linkage under the supplied target JDK, builds the plugin ZIP, runs the real Maven snapshot and target-preview fixtures, checks the connected workflow in both document adapters and native standalone Kit windows, and validates documentation. Real registered IntelliJ UI qualification is a separate command below. It does not install or publish NeitzelLib. Maven is pinned to **3.9.11** (Wrapper **3.3.4**); the separate IntelliJ build uses **Gradle 9.1.0** and IntelliJ Platform Gradle Plugin **2.20.1**. Wrapper distributions have pinned checksums. Shared-module-only validation is `./mvnw -B -ntp clean verify`; it does not qualify IntelliJ packaging. Plugin output is under `designer-intellij/build/distributions/`. +## Run the minimal hosts + +After the full build, launch a disposable fixture with either command; add `--mode modulepath` for named modules: + +```sh +python3 scripts/run-fixture-host.py standalone --java-home "$JAVA_HOME" --target-java-home "$TARGET_JAVA_HOME" +python3 scripts/run-fixture-host.py intellij --java-home "$JAVA_HOME" --target-java-home "$TARGET_JAVA_HOME" --idea-home "$IDEA_HOME" +``` + +The IntelliJ launcher isolates settings/plugins and disables bundled Scene Builder embedding while retaining JavaFX/FXML language support. Select **Neitzel Designer**, then **Build and Refresh**. Both hosts use JavaFX/Kit 26, with the fixture application running separately on Java 21 / JavaFX 21.0.7. + +Qualify the registered editor and loaded JavaFX natives in real disposable IDE windows: + +```sh +python3 scripts/check-ide-host.py --java-home "$JAVA_HOME" --target-java-home "$TARGET_JAVA_HOME" --idea-home "$IDEA_HOME" +``` + +These graphical checks require a macOS desktop session; other platforms are not qualified. See [workflow limits](docs/first-connected-workflow.md#limits-and-next-stage) before using arbitrary application documents. + ## 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. Next: connect the first workflow in both minimal hosts. +- [Repository bootstrap plan](docs/repository-bootstrap-plan.md): ordered work packages and acceptance gates. Bootstrap work packages 1–7 are implemented; next is authoritative document editing and generalizing the Kit projection. - [Decision provenance](docs/provenance.md): workspace planning and captured prototype evidence. -- [Third-party notices](THIRD-PARTY-NOTICES.md): Maven wrapper provenance. +- [Third-party notices](THIRD-PARTY-NOTICES.md): Maven wrapper and patched Kit source provenance. The canonical checkout is `/Volumes/PlanckSSD/KI/java/NeitzelFXDesigner`. Its Git remote is `ssh://git@gitea.neitzel.de:2222/konrad/NeitzelFXDesigner.git` and its default branch is `main`. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 5dd5c76..8d05c58 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -9,3 +9,9 @@ The shared UI depends on Scene Builder Kit 26.0.0 and OpenJFX 26. The IntelliJ a Core configuration validation uses networknt json-schema-validator 1.5.9 and its Jackson dependencies, resolved as Maven artifacts. Preserve their bundled license resources when packaging; [upstream source and license](https://github.com/networknt/json-schema-validator/tree/1.5.9) provide provenance. They do not enter the application-facing preview API or target protocol artifact. The separate Maven capture plugin compiles against provided Apache Maven 3.9.11 APIs and Maven Plugin Tools 3.15.1 annotations. These build-tool dependencies do not enter the shared designer core or target runtime. + +## Narrow Scene Builder Kit patch + +The shared UI contains the Kit 26.0.0 `I18nStringEditor.java` source with only its forced select-all mouse/focus handlers removed. Original copyright and BSD 3-Clause terms remain in the file; the complete upstream license is bundled as `META-INF/SCENEBUILDER-LICENSE`. The patched class is compiled into the shared UI and must precede the unmodified Kit dependency in both hosts. + +Source: Maven Central `com.gluonhq.scenebuilder:kit:26.0.0:sources`, archive SHA-256 `9271ede00df40910e5ebac37090f7d50541bf79ca73ae4094630d0a9535298c3`. This carries the previously verified mouse-selection fix only, not the prototype implementation. The upstream Maven cache is unchanged. diff --git a/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/PreviewMessages.java b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/PreviewMessages.java new file mode 100644 index 0000000..2a1038c --- /dev/null +++ b/designer-contracts/src/main/java/de/neitzel/fxdesigner/protocol/PreviewMessages.java @@ -0,0 +1,42 @@ +package de.neitzel.fxdesigner.protocol; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * Metadata payload format 1. Properties escaping preserves arbitrary labels without class loading. + */ +public final class PreviewMessages { + private PreviewMessages() {} + + public static byte[] encode(Map values) throws IOException { + var p = new Properties(); + p.putAll(values); + var w = new StringWriter(); + p.store(w, null); + byte[] bytes = w.toString().getBytes(StandardCharsets.UTF_8); + if (bytes.length > WireProtocol.MAX_METADATA) throw new IOException("Metadata too large"); + return bytes; + } + + public static Map decode(byte[] bytes) throws IOException { + if (bytes.length > WireProtocol.MAX_METADATA) throw new IOException("Metadata too large"); + var p = + new Properties() { + public synchronized Object put(Object k, Object v) { + if (containsKey(k)) throw new IllegalArgumentException("Duplicate metadata key"); + return super.put(k, v); + } + }; + p.load( + new StringReader( + StandardCharsets.UTF_8 + .newDecoder() + .decode(java.nio.ByteBuffer.wrap(bytes)) + .toString())); + var result = new TreeMap(); + for (String k : p.stringPropertyNames()) result.put(k, p.getProperty(k)); + return Map.copyOf(result); + } +} diff --git a/designer-core/pom.xml b/designer-core/pom.xml index 9424938..6a4659a 100644 --- a/designer-core/pom.xml +++ b/designer-core/pom.xml @@ -40,6 +40,7 @@ **/ConfigurationHostContract.class + **/ConnectedWorkflowContract.class diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/build/BuildRefresh.java b/designer-core/src/main/java/de/neitzel/fxdesigner/build/BuildRefresh.java index 7424268..1a25651 100644 --- a/designer-core/src/main/java/de/neitzel/fxdesigner/build/BuildRefresh.java +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/build/BuildRefresh.java @@ -194,6 +194,15 @@ public final class BuildRefresh implements AutoCloseable { details.put("buildJava", Integer.toString(build.javaFeature())); details.put("targetJavaHome", target.javaHome().toString()); details.put("targetJava", Integer.toString(target.javaFeature())); + details.put( + "sourceRoots", + new com.fasterxml.jackson.databind.ObjectMapper() + .writeValueAsString( + captured.reactor().modules().stream() + .flatMap(m -> m.sourceRoots().stream()) + .map(Path::toString) + .distinct() + .toList())); details.put("wireVersion", "1.0"); details.put("manifestVersion", "1"); details.put("coordinates", captured.coordinates().toString()); @@ -359,6 +368,14 @@ public final class BuildRefresh implements AutoCloseable { } public SnapshotStore.Lease previewCurrent(Request request) throws Exception { + return previewCurrent(request, Set.of()); + } + + /** + * Saved FXML may differ only when that exact resource is supplied by the immutable draft overlay. + */ + public SnapshotStore.Lease previewCurrent(Request request, Set overlaidFXML) + throws Exception { var state = store.state(request.context()); if (state.outdated() || state.lastSuccessful() == null) throw new IllegalStateException("Choose Preview with last successful build explicitly"); @@ -371,7 +388,7 @@ public final class BuildRefresh implements AutoCloseable { .get("configuration") .equals(request.configuration().toRealPath().toUri().toString()) || !runtimeMatches(request, lease.snapshot()) - || !lease.snapshot().inputs().equals(fingerprint(root)) + || !inputsMatch(lease.snapshot().inputs(), fingerprint(root), root, overlaidFXML) || !dirty(root).isEmpty()) throw new IllegalStateException( "Build is outdated; choose last successful build explicitly"); @@ -383,6 +400,23 @@ public final class BuildRefresh implements AutoCloseable { } } + private static boolean inputsMatch( + Map captured, Map current, Path root, Set overlays) + throws IOException { + var before = new TreeMap<>(captured); + var after = new TreeMap<>(current); + for (URI uri : overlays) { + Path file = Path.of(uri).toRealPath(); + if (!file.startsWith(root) || !file.toString().endsWith(".fxml")) continue; + String key = root.relativize(file).toString(); + if (before.containsKey(key) && after.containsKey(key)) { + before.remove(key); + after.remove(key); + } + } + return before.equals(after); + } + private boolean runtimeMatches(Request request, SnapshotStore.Snapshot snapshot) throws Exception { var context = 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 9fe4c50..1cff2cc 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 @@ -26,6 +26,15 @@ public record HostServices( public interface Documents { CompletionStage open(URI document); + /** Fired synchronously with host edits; listeners must not block the host thread. */ + default AutoCloseable onChange(Consumer listener) { + return () -> {}; + } + + default CompletionStage> snapshots() { + return unsaved(); + } + default CompletionStage> unsaved() { return java.util.concurrent.CompletableFuture.failedFuture( new UnsupportedOperationException("Host must enumerate known unsaved build inputs")); 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 new file mode 100644 index 0000000..51b7b3d --- /dev/null +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DesignerWorkflow.java @@ -0,0 +1,288 @@ +package de.neitzel.fxdesigner.workflow; + +import static de.neitzel.fxdesigner.protocol.WireProtocol.*; + +import de.neitzel.fxdesigner.build.*; +import de.neitzel.fxdesigner.contracts.*; +import de.neitzel.fxdesigner.host.*; +import de.neitzel.fxdesigner.protocol.*; +import java.net.URI; +import java.nio.file.*; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.*; + +/** + * Shared build, discovery and render owner. Hosts supply only documents, runtimes and presentation. + */ +public final class DesignerWorkflow implements AutoCloseable { + public record Example(String id, String label, String viewResource, String componentPackage) {} + + public record Image( + String node, + int width, + int height, + byte[] rgba, + Map revisions, + String snapshot) { + public Image { + rgba = rgba.clone(); + revisions = Map.copyOf(revisions); + } + + public byte[] rgba() { + return rgba.clone(); + } + } + + private final HostServices host; + private final BuildRefresh build; + private final BuildRefresh.Request request; + private final List tools; + private final Consumer log; + private PreviewWorker active; + private long generation; + private boolean closed; + + public DesignerWorkflow( + HostServices host, + Path configuration, + String context, + Path maven, + List tools, + Consumer log) + throws Exception { + this.host = host; + this.tools = List.copyOf(tools); + this.log = log; + request = new BuildRefresh.Request(configuration, context, maven); + build = + new BuildRefresh( + host, + new SnapshotStore( + configuration + .toAbsolutePath() + .getParent() + .resolve(".neitzel-fx-designer/snapshots"))); + host.lifecycle().own(this); + } + + public CompletionStage componentSource(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())) { + var roots = + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(lease.snapshot().details().get("sourceRoots"), String[].class); + var matches = new LinkedHashSet(); + for (String root : roots) { + Path path = Path.of(root).resolve(type.replace('.', '/') + ".fxml"); + if (Files.isRegularFile(path)) matches.add(path.toRealPath()); + } + if (matches.size() != 1) + 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); + } + }); + } + + public BuildRefresh.Run build(Map approved) { + cancel(); + return build.start(request, approved, line -> log.accept(line.line())); + } + + public CompletionStage> discover(boolean lastSuccessful) { + return execute( + lastSuccessful, + List.of(), + worker -> { + var packet = + worker.request( + Operation.DISCOVER, Map.of(), 0, 0, new byte[0], Duration.ofSeconds(15)); + var data = PreviewMessages.decode(packet.payload()); + if (!"1".equals(data.get("format"))) + throw new IllegalArgumentException("Unsupported metadata version"); + int count = Integer.parseInt(data.get("count")); + if (count < 0 || count > 1024) + throw new IllegalArgumentException("Invalid example count"); + var result = new ArrayList(); + var config = worker.context(); + 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")) + && (config.preview().examples().isEmpty() + || config.preview().examples().contains(id))) + result.add( + new Example( + id, + data.get(p + "label"), + data.get(p + "viewResource"), + data.get(p + "componentPackage"))); + } + return List.copyOf(result); + }); + } + + public record Size(int width, int height) {} + + public CompletionStage render( + boolean lastSuccessful, + String example, + String node, + int width, + int height, + List drafts) { + return renderImages(lastSuccessful, example, Map.of(node, new Size(width, height)), drafts) + .thenApply(images -> images.getFirst()); + } + + /** All Component images in this batch come from one loaded parent and one shared Model graph. */ + public CompletionStage> renderImages( + boolean lastSuccessful, + String example, + Map nodes, + List drafts) { + var requested = new LinkedHashMap<>(nodes); + var captured = List.copyOf(drafts); + if (requested.isEmpty() || requested.size() > 128) + return CompletableFuture.failedFuture( + new IllegalArgumentException("Invalid render node count")); + long bytes = 0; + for (var size : requested.values()) { + if (size.width() < 1 + || size.height() < 1 + || size.width() > 8192 + || size.height() > 8192 + || (long) size.width() * size.height() * 4 > MAX_IMAGE) + return CompletableFuture.failedFuture( + new IllegalArgumentException("Invalid render dimensions")); + bytes += (long) size.width() * size.height() * 4; + } + if (bytes > 64L * 1024 * 1024) + return CompletableFuture.failedFuture( + new IllegalArgumentException("Render batch exceeds 64 MiB")); + return execute( + lastSuccessful, + captured, + worker -> { + var revisions = new TreeMap(); + captured.forEach(d -> revisions.put(d.document().toString(), d.revision())); + var images = new ArrayList(); + for (var entry : requested.entrySet()) { + var size = entry.getValue(); + String node = entry.getKey(); + var result = + worker.request( + Operation.RENDER, + revisions, + size.width(), + size.height(), + PreviewMessages.encode(Map.of("example", example, "node", node)), + Duration.ofSeconds(30)); + images.add( + new Image( + node, + size.width(), + size.height(), + result.payload(), + revisions, + result.identity().snapshot())); + } + for (var draft : captured) { + try (var doc = host.documents().open(draft.document()).toCompletableFuture().get()) { + if (doc.snapshot().toCompletableFuture().get().revision() != draft.revision()) + throw new CancellationException("Draft changed during rendering"); + } + } + return List.copyOf(images); + }); + } + + private interface Work { + T run(PreviewWorker worker) throws Exception; + } + + private CompletionStage execute( + boolean last, List drafts, Work work) { + cancel(); + long ticket; + synchronized (this) { + if (closed) + return CompletableFuture.failedFuture(new IllegalStateException("Designer closed")); + ticket = generation; + } + var result = new CompletableFuture(); + Thread.ofVirtual() + .start( + () -> { + PreviewWorker worker = null; + try { + var lease = + last + ? build.previewLastSuccessful(request.context()) + : build.previewCurrent( + request, + drafts.stream() + .map(DocumentSnapshot::document) + .collect(java.util.stream.Collectors.toSet())); + worker = new PreviewWorker(new DraftOverlay(lease, drafts)); + synchronized (this) { + if (closed || ticket != generation) throw new CancellationException(); + active = worker; + } + var owned = worker; + // Hard deadline also covers unresponsive FX, construction and handshake. + var watchdog = + Thread.ofVirtual() + .start( + () -> { + try { + Thread.sleep(45000); + owned.close(); + } catch (InterruptedException ignored) { + } + }); + try { + worker.start(tools, log); + T value = work.run(worker); + synchronized (this) { + if (closed || ticket != generation) throw new CancellationException(); + result.complete(value); + } + } finally { + watchdog.interrupt(); + } + } catch (Throwable e) { + result.completeExceptionally(e); + } finally { + if (worker != null) worker.close(); + synchronized (this) { + if (active == worker) active = null; + } + } + }); + return result; + } + + public synchronized void cancel() { + generation++; + var old = active; + active = null; + if (old != null) Thread.ofVirtual().start(old::close); + } + + public synchronized void close() { + if (closed) return; + closed = true; + cancel(); + build.close(); + } +} diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DraftOverlay.java b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DraftOverlay.java new file mode 100644 index 0000000..6a2f58e --- /dev/null +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/DraftOverlay.java @@ -0,0 +1,156 @@ +package de.neitzel.fxdesigner.workflow; + +import de.neitzel.fxdesigner.build.SnapshotStore; +import de.neitzel.fxdesigner.contracts.DocumentSnapshot; +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.zip.*; + +/** Private runtime copies, retaining their immutable snapshot lease until the worker exits. */ +public final class DraftOverlay implements AutoCloseable { + private final SnapshotStore.Lease lease; + private final Path directory; + private final List runtime = new ArrayList<>(); + private final Map revisions = new TreeMap<>(); + + public DraftOverlay(SnapshotStore.Lease lease, List drafts) throws Exception { + this.lease = lease; + directory = Files.createTempDirectory("neitzel-preview-"); + try { + if (drafts.size() > 128) throw new IOException("Too many draft overlays"); + var roots = + new com.fasterxml.jackson.databind.ObjectMapper() + .readValue( + lease.snapshot().details().getOrDefault("sourceRoots", "[]"), String[].class); + var resources = new TreeMap(); + for (var draft : drafts) { + Path file = Path.of(draft.document()).toAbsolutePath().normalize(); + if (!file.toString().endsWith(".fxml")) continue; + var matches = new HashSet(); + for (String root : roots) { + Path base = Path.of(root); + if (file.startsWith(base)) + matches.add(base.relativize(file).toString().replace(File.separatorChar, '/')); + } + if (matches.size() != 1) + throw new IOException("FXML draft has no unambiguous captured source root: " + file); + String name = matches.iterator().next(); + byte[] bytes = draft.text().getBytes(java.nio.charset.StandardCharsets.UTF_8); + if (bytes.length > 1024 * 1024) throw new IOException("FXML draft exceeds 1 MiB"); + if (resources.putIfAbsent(name, bytes) != null) + throw new IOException("Ambiguous FXML resource: " + name); + revisions.put(draft.document().toString(), draft.revision()); + } + var found = new HashSet(); + int i = 0; + for (Path source : lease.runtime()) { + boolean affected; + if (Files.isDirectory(source)) + affected = + resources.keySet().stream() + .anyMatch(name -> Files.isRegularFile(source.resolve(name))); + else + try (var jar = new ZipFile(source.toFile())) { + affected = resources.keySet().stream().anyMatch(name -> jar.getEntry(name) != null); + } + if (!affected) { + runtime.add(source); + continue; + } + Path dest = directory.resolve(Integer.toString(i++)).resolve(source.getFileName()); + Files.createDirectories(dest.getParent()); + if (Files.isDirectory(source)) { + try (var paths = Files.walk(source)) { + for (Path p : paths.toList()) { + Path target = dest.resolve(source.relativize(p)); + if (Files.isDirectory(p)) Files.createDirectories(target); + else Files.copy(p, target); + } + } + for (var e : resources.entrySet()) { + Path file = dest.resolve(e.getKey()); + if (Files.isRegularFile(file)) { + Files.write(file, e.getValue()); + found.add(e.getKey()); + } + } + } else { + try (var in = new ZipFile(source.toFile()); + var out = new ZipOutputStream(Files.newOutputStream(dest))) { + var entries = in.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + String name = entry.getName(); + if (!resources.isEmpty() + && name.startsWith("META-INF/") + && (name.endsWith(".SF") || name.endsWith(".RSA"))) + throw new IOException("Signed runtime JAR cannot be overlaid"); + out.putNextEntry(new ZipEntry(name)); + if (resources.containsKey(name)) { + out.write(resources.get(name)); + found.add(name); + } else + try (var stream = in.getInputStream(entry)) { + stream.transferTo(out); + } + out.closeEntry(); + } + } + } + runtime.add(dest); + } + if (!found.containsAll(resources.keySet())) + throw new IOException("Draft resource absent from captured runtime"); + } catch (Exception e) { + close(); + throw e; + } + } + + public List copyTools(List tools) throws Exception { + var result = new ArrayList(); + int index = 0; + for (Path source : tools) { + Path target = + directory + .resolve("tools") + .resolve(Integer.toString(index++)) + .resolve(source.getFileName()); + Files.createDirectories(target.getParent()); + byte[] before = + java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(source)); + Files.copy(source, target); + byte[] copied = + java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(target)); + byte[] after = + java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(source)); + if (!Arrays.equals(before, copied) || !Arrays.equals(before, after)) + throw new IOException("Target tooling changed during launch"); + result.add(target); + } + return List.copyOf(result); + } + + public List runtime() { + return List.copyOf(runtime); + } + + public Map revisions() { + return Map.copyOf(revisions); + } + + public SnapshotStore.Snapshot snapshot() { + return lease.snapshot(); + } + + public void close() throws IOException { + try { + try (var paths = Files.walk(directory)) { + for (Path p : paths.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(p); + } + } finally { + lease.close(); + } + } +} diff --git a/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/HostLaunch.java b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/HostLaunch.java new file mode 100644 index 0000000..cb4a1b5 --- /dev/null +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/HostLaunch.java @@ -0,0 +1,42 @@ +package de.neitzel.fxdesigner.workflow; + +import de.neitzel.fxdesigner.config.LocalSettings; +import java.nio.file.*; +import java.util.*; + +/** Explicit machine-local launch setup, independent of application configuration. */ +public record HostLaunch( + Path configuration, String context, Path maven, List tools, LocalSettings runtimes) { + public static HostLaunch load(Path file) throws Exception { + var p = new Properties(); + try (var reader = Files.newBufferedReader(file)) { + p.load(reader); + } + Path base = file.toAbsolutePath().getParent(); + Path config = resolve(base, p.getProperty("configuration")); + var contexts = de.neitzel.fxdesigner.config.ConfigurationFiles.load(config).contexts(); + String context = p.getProperty("context"); + if (context == null && contexts.size() == 1) context = contexts.keySet().iterator().next(); + if (context == null || !contexts.containsKey(context)) + throw new IllegalArgumentException("Select a configured context in " + file); + var tools = new ArrayList(); + for (String item : p.getProperty("tools", "").split(java.io.File.pathSeparator)) { + Path path = resolve(base, item); + if (!Files.isRegularFile(path)) + throw new IllegalArgumentException("Target tooling JAR missing: " + path); + tools.add(path); + } + return new HostLaunch( + config, + context, + p.containsKey("maven") ? resolve(base, p.getProperty("maven")) : null, + List.copyOf(tools), + LocalSettings.load(resolve(base, p.getProperty("settings")))); + } + + private static Path resolve(Path base, String value) { + if (value == null || value.isBlank()) + throw new IllegalArgumentException("Incomplete local designer launch settings"); + return base.resolve(value).normalize().toAbsolutePath(); + } +} 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 new file mode 100644 index 0000000..bb7da96 --- /dev/null +++ b/designer-core/src/main/java/de/neitzel/fxdesigner/workflow/PreviewWorker.java @@ -0,0 +1,192 @@ +package de.neitzel.fxdesigner.workflow; + +import static de.neitzel.fxdesigner.protocol.WireProtocol.*; + +import de.neitzel.fxdesigner.config.DesignerConfiguration; +import de.neitzel.fxdesigner.protocol.*; +import java.io.*; +import java.net.*; +import java.nio.file.*; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Consumer; + +/** + * One target process, authenticated socket and reserved owner pipe. All blocking calls run off UI. + */ +public final class PreviewWorker implements AutoCloseable { + private final DraftOverlay overlay; + private Process process; + private Socket socket; + private ServerSocket server; + private ProtocolSession protocol; + private volatile boolean closed; + + public PreviewWorker(DraftOverlay overlay) { + this.overlay = overlay; + } + + public synchronized long pid() { + return process == null ? -1 : process.pid(); + } + + public DesignerConfiguration.Context context() throws Exception { + return new com.fasterxml.jackson.databind.ObjectMapper() + .readValue( + overlay.snapshot().details().get("contextSpec"), DesignerConfiguration.Context.class); + } + + public void start(List tools, Consumer log) throws Exception { + var snapshot = overlay.snapshot(); + var config = context(); + var command = new ArrayList(); + command.add(Path.of(snapshot.details().get("targetJavaHome"), "bin", "java").toString()); + ArrayList paths; + synchronized (this) { + if (closed) throw new CancellationException(); + paths = new ArrayList<>(overlay.copyTools(tools)); + } + paths.addAll(overlay.runtime()); + boolean modular = config.launchMode().equals("module-path"); + if (modular) { + var modules = new LinkedHashMap(); + for (Path path : paths) { + var refs = java.lang.module.ModuleFinder.of(path).findAll(); + if (refs.size() != 1) throw new IOException("Not a single module: " + path); + String name = refs.iterator().next().descriptor().name(); + modules.putIfAbsent(name, path); + } + if (!modules.containsKey(config.modulePath().entryModule())) + throw new IOException("Configured entry module absent from snapshot"); + command.addAll( + List.of("--module-path", join(modules.values()), "--add-modules", "ALL-MODULE-PATH")); + for (String value : config.modulePath().addReads()) + command.addAll(List.of("--add-reads", value)); + for (String value : config.modulePath().addOpens()) + command.addAll(List.of("--add-opens", value)); + command.addAll( + List.of("-m", "de.neitzel.fxdesigner.target/de.neitzel.fxdesigner.target.WorkerMain")); + } else command.addAll(List.of("-cp", join(paths), "de.neitzel.fxdesigner.target.WorkerMain")); + String id = UUID.randomUUID().toString(), token = ProtocolSession.newToken(); + synchronized (this) { + if (closed) throw new CancellationException(); + server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()); + server.setSoTimeout(15000); + command.add(Integer.toString(server.getLocalPort())); + command.add(id); + process = new ProcessBuilder(command).start(); + protocol = + new ProtocolSession( + id, + process.pid(), + token, + Set.of("discover-v1", "render-rgba-v1"), + config.requiredCapabilities()); + } + drain(process.getInputStream(), log); + drain(process.getErrorStream(), log); + var owner = new DataOutputStream(process.getOutputStream()); + owner.writeUTF(token); + owner.flush(); + Socket connection = server.accept(); + synchronized (this) { + if (closed) { + connection.close(); + throw new CancellationException(); + } + socket = connection; + } + socket.setSoTimeout(15000); + var hello = FrameCodec.read(socket.getInputStream()); + if (!(hello instanceof Hello h)) throw new IOException("Worker did not send HELLO"); + protocol.authenticate(socket.getInetAddress(), h); + FrameCodec.write(socket.getOutputStream(), protocol.welcome()); + socket.getOutputStream().flush(); + server.close(); + } + + private static String join(Collection paths) { + return String.join(File.pathSeparator, paths.stream().map(Path::toString).toList()); + } + + private static void drain(InputStream input, Consumer log) { + Thread.ofVirtual() + .start( + () -> { + try (var reader = new BufferedReader(new InputStreamReader(input))) { + char[] chunk = new char[2048]; + int n; + while ((n = reader.read(chunk)) >= 0) log.accept(new String(chunk, 0, n)); + } catch (IOException ignored) { + } + }); + } + + public Packet request( + Operation operation, + Map revisions, + int width, + int height, + byte[] payload, + Duration timeout) + throws Exception { + if (closed) throw new CancellationException(); + var snapshot = overlay.snapshot(); + var packet = + protocol.request( + operation, + snapshot.context(), + snapshot.id(), + revisions, + width, + height, + payload, + Instant.now(), + timeout); + socket.setSoTimeout(Math.toIntExact(timeout.toMillis())); + FrameCodec.write(socket.getOutputStream(), packet); + socket.getOutputStream().flush(); + var response = FrameCodec.read(socket.getInputStream()); + if (!(response instanceof Packet result) || !protocol.accept(result, Instant.now())) + throw new IOException("Stale or unrelated worker response"); + if (result.kind() == Kind.ERROR) + throw new IOException(new String(result.payload(), java.nio.charset.StandardCharsets.UTF_8)); + return result; + } + + @Override + public void close() { + Process child; + synchronized (this) { + if (closed) return; + closed = true; + child = process; + try { + if (server != null) server.close(); + } catch (IOException ignored) { + } + try { + if (socket != null) socket.close(); + } catch (IOException ignored) { + } + if (protocol != null) protocol.close(); + } + if (child != null) { + try { + child.getOutputStream().close(); + if (!child.waitFor(2, TimeUnit.SECONDS)) { + child.descendants().forEach(ProcessHandle::destroyForcibly); + child.destroyForcibly(); + child.waitFor(3, TimeUnit.SECONDS); + } + } catch (Exception e) { + child.destroyForcibly(); + } + } + try { + overlay.close(); + } catch (IOException ignored) { + } + } +} 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 new file mode 100644 index 0000000..562ddf5 --- /dev/null +++ b/designer-core/src/test/java/de/neitzel/fxdesigner/workflow/ConnectedWorkflowContract.java @@ -0,0 +1,175 @@ +package de.neitzel.fxdesigner.workflow; + +import de.neitzel.fxdesigner.build.*; +import de.neitzel.fxdesigner.contracts.*; +import de.neitzel.fxdesigner.host.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; + +public final class ConnectedWorkflowContract { + private int checks; + + private void check(boolean value, String message) { + if (!value) throw new AssertionError(message); + checks++; + } + + private static T await(CompletionStage stage) throws Exception { + return stage.toCompletableFuture().get(120, TimeUnit.SECONDS); + } + + public int run(HostServices host, Path root, Path fixture) throws Exception { + var tools = + List.of( + root.resolve("designer-target-tools/target/designer-target-tools-0.1.0-SNAPSHOT.jar"), + root.resolve("designer-contracts/target/designer-contracts-0.1.0-SNAPSHOT.jar"), + root.resolve("designer-preview-api/target/designer-preview-api-0.1.0-SNAPSHOT.jar")); + var workflow = + new DesignerWorkflow( + host, + fixture.resolve("neitzel-fx-designer.json"), + "fixture", + root.resolve("mvnw"), + tools, + System.out::println); + 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"); + 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())); + var component = await(host.documents().open(child.toUri()))) { + 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"); + var batch = + await( + workflow.renderImages( + false, + "standard", + Map.of( + "card", + new DesignerWorkflow.Size(310, 220), + "second", + new DesignerWorkflow.Size(310, 220)), + List.of(initial))); + check( + batch.size() == 2 + && batch.stream().allMatch(image -> image.snapshot().equals(first.snapshot())), + "Shared parent render batch"); + check( + Path.of(await(workflow.componentSource("fixture.components.Card"))) + .equals(child.toRealPath()), + "Component navigation source"); + + var changed = + await( + doc.apply( + new EditBatch( + initial.revision(), + "Caption", + List.of( + new EditBatch.Replacement( + 0, + initial.text().length(), + initial.text().replace("FXML caption", "Draft caption")))))); + var second = await(workflow.render(false, "standard", "card", 310, 220, List.of(changed))); + 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()); + var childDraft = + await( + component.apply( + new EditBatch( + childInitial.revision(), + "Component", + List.of( + new EditBatch.Replacement( + 0, + childInitial.text().length(), + childInitial + .text() + .replace( + " + providers.systemProperty(key).orNull?.let { systemProperty(key, it) } + } +} diff --git a/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/DesignerEditorProvider.java b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/DesignerEditorProvider.java new file mode 100644 index 0000000..c5c2f68 --- /dev/null +++ b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/DesignerEditorProvider.java @@ -0,0 +1,23 @@ +package de.neitzel.fxdesigner.intellij; + +import com.intellij.openapi.fileEditor.*; +import com.intellij.openapi.project.*; +import com.intellij.openapi.vfs.VirtualFile; + +public final class DesignerEditorProvider implements FileEditorProvider, DumbAware { + public boolean accept(Project project, VirtualFile file) { + return !file.isDirectory() && "fxml".equalsIgnoreCase(file.getExtension()); + } + + public FileEditor createEditor(Project project, VirtualFile file) { + return new DesignerFileEditor(project, file); + } + + public String getEditorTypeId() { + return "neitzel-fx-designer"; + } + + public FileEditorPolicy getPolicy() { + return FileEditorPolicy.PLACE_AFTER_DEFAULT_EDITOR; + } +} 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 new file mode 100644 index 0000000..2015e14 --- /dev/null +++ b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/DesignerFileEditor.java @@ -0,0 +1,171 @@ +package de.neitzel.fxdesigner.intellij; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.fileEditor.*; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.UserDataHolderBase; +import com.intellij.openapi.vfs.VirtualFile; +import de.neitzel.fxdesigner.kit.DesignerPane; +import de.neitzel.fxdesigner.workflow.HostLaunch; +import java.awt.BorderLayout; +import java.beans.*; +import java.nio.file.*; +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; +import javafx.scene.Scene; +import javax.swing.*; + +/** IDE-owned FXML remains available in its normal text editor when designer setup fails. */ +public final class DesignerFileEditor extends UserDataHolderBase implements FileEditor { + private final VirtualFile file; + private final JPanel panel = new JPanel(new BorderLayout()); + private final JLabel status = new JLabel("Checking exclusive JavaFX setup…"); + private final PropertyChangeSupport changes = new PropertyChangeSupport(this); + private volatile boolean disposed; + private IntellijHost host; + private DesignerPane pane; + private JFXPanel bridge; + private Timer gate; + + DesignerFileEditor(Project project, VirtualFile file) { + this.file = file; + panel.add(status, BorderLayout.NORTH); + ApplicationManager.getApplication() + .executeOnPooledThread( + () -> { + try { + ToolkitGate.check(project, file); + Path settings = + Path.of( + System.getProperty( + "neitzel.designer.launch", + Path.of( + project.getBasePath(), + ".neitzel-fx-designer", + "host.properties") + .toString())); + var launch = HostLaunch.load(settings); + SwingUtilities.invokeLater( + () -> { + if (disposed) return; + try { + host = + new IntellijHost( + project, + launch.runtimes(), + d -> SwingUtilities.invokeLater(() -> status.setText(d.message()))); + var previous = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); + bridge = new JFXPanel(); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + panel.add(bridge, BorderLayout.CENTER); + panel.revalidate(); + Platform.setImplicitExit(false); + Platform.runLater( + () -> { + if (disposed) return; + try { + Thread.currentThread() + .setContextClassLoader(getClass().getClassLoader()); + pane = + new DesignerPane( + host.services(), launch, Path.of(file.getPath())); + bridge.setScene(new Scene(pane, 1200, 800)); + SwingUtilities.invokeLater( + () -> + status.setText( + "Neitzel FX Designer · JavaFX 26 · native IDE" + + " documents")); + } catch (Exception e) { + failed(e); + } + }); + gate = + new Timer( + 500, + e -> { + if (!ToolkitGate.allowed()) { + status.setText( + "JavaFX configuration changed; restart required"); + Platform.runLater( + () -> { + if (pane != null) { + pane.close(); + pane.setDisable(true); + } + }); + } + }); + gate.start(); + } catch (Exception e) { + failed(e); + } + }); + } catch (Exception e) { + failed(e); + } + }); + } + + private void failed(Throwable error) { + SwingUtilities.invokeLater( + () -> + status.setText( + "Designer unavailable: " + + error.getMessage() + + ". Source editing remains available.")); + } + + public JComponent getComponent() { + return panel; + } + + public JComponent getPreferredFocusedComponent() { + return bridge == null ? panel : bridge; + } + + public String getName() { + return "Neitzel Designer"; + } + + public boolean isModified() { + return false; + } + + public boolean isValid() { + return !disposed && file.isValid(); + } + + public VirtualFile getFile() { + return file; + } + + public void setState(FileEditorState state) {} + + public void addPropertyChangeListener(PropertyChangeListener listener) { + changes.addPropertyChangeListener(listener); + } + + public void removePropertyChangeListener(PropertyChangeListener listener) { + changes.removePropertyChangeListener(listener); + } + + public void dispose() { + disposed = true; + if (gate != null) gate.stop(); + if (pane != null) + Platform.runLater( + () -> + pane.flush() + .whenComplete( + (v, error) -> { + pane.close(); + bridge.setScene(null); + if (host != null) host.close(); + })); + else if (host != null) host.close(); + } +} 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 6cf12e5..15ff96c 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 @@ -23,6 +23,14 @@ import java.util.concurrent.atomic.AtomicLong; * store. */ public final class IntellijDocuments implements HostServices.Documents, AutoCloseable { + private final java.util.concurrent.CopyOnWriteArrayList> + changes = new java.util.concurrent.CopyOnWriteArrayList<>(); + + public AutoCloseable onChange(java.util.function.Consumer listener) { + changes.add(listener); + return () -> changes.remove(listener); + } + private final Project project; private final com.intellij.openapi.Disposable lifetime; private final Map states = new HashMap<>(); @@ -40,6 +48,17 @@ public final class IntellijDocuments implements HostServices.Documents, AutoClos this.project = Objects.requireNonNull(project); lifetime = this::disposeResources; Disposer.register(project, lifetime); + EditorFactory.getInstance() + .getEventMulticaster() + .addDocumentListener( + new DocumentListener() { + public void documentChanged(DocumentEvent event) { + var file = FileDocumentManager.getInstance().getFile(event.getDocument()); + if (file != null && file.isInLocalFileSystem()) + changes.forEach(listener -> listener.accept(canonicalUri(file))); + } + }, + lifetime); } private CompletionStage edt(Callable action) { @@ -57,18 +76,19 @@ public final class IntellijDocuments implements HostServices.Documents, AutoClos } }); if (ApplicationManager.getApplication().isDispatchThread()) task.run(); - else ApplicationManager.getApplication().invokeLater(task, ModalityState.any()); + else ApplicationManager.getApplication().invokeLater(task, ModalityState.nonModal()); return future; } public CompletionStage open(URI uri) { return edt( () -> { - Path path = Path.of(uri).toRealPath(); + Path supplied = Path.of(uri).toAbsolutePath().normalize(); + Path path = supplied.toRealPath(); State state = states.get(path); if (state == null) { state = new State(); - state.file = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path); + state.file = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(supplied); if (state.file == null) throw new IllegalArgumentException("Missing file: " + path); state.document = FileDocumentManager.getInstance().getDocument(state.file); if (state.document == null) throw new IllegalArgumentException("Not a text document"); @@ -90,6 +110,17 @@ public final class IntellijDocuments implements HostServices.Documents, AutoClos }); } + public CompletionStage> snapshots() { + return edt( + () -> { + unsaved().toCompletableFuture().join(); + for (var file : FileEditorManager.getInstance(project).getOpenFiles()) + if (file.isInLocalFileSystem() && "fxml".equalsIgnoreCase(file.getExtension())) + open(Path.of(file.getPath()).toUri()).toCompletableFuture().join().close(); + return states.values().stream().map(this::snapshot).toList(); + }); + } + public CompletionStage> unsaved() { return edt( () -> { @@ -110,9 +141,17 @@ public final class IntellijDocuments implements HostServices.Documents, AutoClos }); } + private static URI canonicalUri(VirtualFile file) { + try { + return Path.of(file.getPath()).toRealPath().toUri(); + } catch (java.io.IOException e) { + return Path.of(file.getPath()).toAbsolutePath().normalize().toUri(); + } + } + private DocumentSnapshot snapshot(State s) { return new DocumentSnapshot( - Path.of(s.file.getPath()).toUri(), + canonicalUri(s.file), s.revision.get(), s.document.getText(), s.document.isWritable() && s.file.isWritable(), diff --git a/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/ToolkitGate.java b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/ToolkitGate.java new file mode 100644 index 0000000..3868e04 --- /dev/null +++ b/designer-intellij/src/main/java/de/neitzel/fxdesigner/intellij/ToolkitGate.java @@ -0,0 +1,70 @@ +package de.neitzel.fxdesigner.intellij; + +import com.intellij.openapi.application.*; +import com.intellij.openapi.fileEditor.FileEditorProvider; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.registry.*; +import com.intellij.openapi.vfs.VirtualFile; +import java.nio.file.*; +import java.util.concurrent.*; + +/** + * Qualified exclusive JavaFX 26 setup. Never initializes Glass when bundled embedding is enabled. + */ +final class ToolkitGate { + private static boolean initialized; + private static volatile boolean revoked; + + static synchronized void check(Project project, VirtualFile file) throws Exception { + if (revoked) throw new IllegalStateException("JavaFX configuration changed; restart IntelliJ"); + if (!ApplicationInfo.getInstance().getBuild().asString().endsWith("263.7307.200")) + throw new IllegalStateException("Designer requires the qualified IntelliJ 263.7307.200 SDK"); + if (Registry.is("embed.scene.builder")) + throw new IllegalStateException( + "Disable embed.scene.builder in IntelliJ Registry and restart. Keep JavaFX/FXML language" + + " support enabled."); + var provider = + FileEditorProvider.EP_FILE_EDITOR_PROVIDER.getExtensionList().stream() + .filter(p -> p.getEditorTypeId().equals("JavaFX-Scene-Builder")) + .findFirst() + .orElseThrow( + () -> new IllegalStateException("Bundled Scene Builder contract unavailable")); + if (ReadAction.compute(() -> provider.accept(project, file))) + throw new IllegalStateException("Bundled Scene Builder still accepts FXML; restart IntelliJ"); + if (initialized) return; + Path log = Files.createTempFile("designer-native-", ".txt"); + try { + var process = + new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "jcmd").toString(), + Long.toString(ProcessHandle.current().pid()), + "VM.dynlibs") + .redirectErrorStream(true) + .redirectOutput(log.toFile()) + .start(); + if (!process.waitFor(15, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IllegalStateException("Cannot inspect loaded JavaFX libraries"); + } + if (process.exitValue() != 0 || Files.readString(log).contains("libglass.")) + throw new IllegalStateException( + "JavaFX already loaded or native inspection unavailable; restart with bundled Scene" + + " Builder disabled"); + } finally { + Files.deleteIfExists(log); + } + Registry.get("embed.scene.builder") + .addListener( + new RegistryValueListener() { + public void afterValueChanged(RegistryValue value) { + revoked = true; + } + }, + ApplicationManager.getApplication()); + initialized = true; + } + + static boolean allowed() { + return !revoked && !Registry.is("embed.scene.builder"); + } +} diff --git a/designer-intellij/src/main/resources/META-INF/plugin.xml b/designer-intellij/src/main/resources/META-INF/plugin.xml index 5561ddb..547c701 100644 --- a/designer-intellij/src/main/resources/META-INF/plugin.xml +++ b/designer-intellij/src/main/resources/META-INF/plugin.xml @@ -1,5 +1,8 @@ de.neitzel.fxdesignerNeitzel FX DesignerNeitzel - Module and host-service foundation. The FXML designer UI is not registered yet. + Shared Kit designer with isolated target previews and IDE-owned FXML documents. com.intellij.modules.platform + + + 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 new file mode 100644 index 0000000..12bd597 --- /dev/null +++ b/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/ConnectedWorkflowTest.java @@ -0,0 +1,52 @@ +package de.neitzel.fxdesigner.intellij; + +import com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import com.intellij.util.ui.UIUtil; +import de.neitzel.fxdesigner.host.*; +import de.neitzel.fxdesigner.workflow.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; + +/** Same real build/socket/draft contract as standalone, using actual IDE Documents and commands. */ +public final class ConnectedWorkflowTest extends BasePlatformTestCase { + public void testClasspathAndModulePath() throws Exception { + String fixtures = System.getProperty("designer.workflow.fixtures"); + if (fixtures == null) return; + Path root = Path.of(System.getProperty("designer.repository")), + java = Path.of(System.getProperty("designer.target.java")); + var runtimes = + new ConfiguredRuntimes( + Map.of( + new ConfiguredRuntimes.Key("java21", HostServices.RuntimeRole.BUILD), + new HostServices.RuntimeInstallation(java, 21), + new ConfiguredRuntimes.Key("java21", HostServices.RuntimeRole.TARGET), + new HostServices.RuntimeInstallation(java, 21))); + VfsRootAccess.allowRootAccess( + getTestRootDisposable(), Path.of(fixtures).toRealPath().toString(), root.toString()); + try (var host = + new IntellijHost(getProject(), runtimes, d -> System.out.println(d.message()))) { + var future = + CompletableFuture.supplyAsync( + () -> { + try { + int count = 0; + for (String mode : List.of("classpath", "modulepath")) + count += + new ConnectedWorkflowContract() + .run(host.services(), root, Path.of(fixtures, mode)); + return count; + } catch (Exception e) { + throw new CompletionException(e); + } + }); + long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(5); + while (!future.isDone() && System.nanoTime() < deadline) { + UIUtil.dispatchAllInvocationEvents(); + Thread.sleep(10); + } + assertEquals(32, (int) future.get(1, TimeUnit.SECONDS)); + } + } +} diff --git a/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/IntellijDocumentsTest.java b/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/IntellijDocumentsTest.java index caccb52..a14bea4 100644 --- a/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/IntellijDocumentsTest.java +++ b/designer-intellij/src/test/java/de/neitzel/fxdesigner/intellij/IntellijDocumentsTest.java @@ -9,7 +9,8 @@ public final class IntellijDocumentsTest extends BasePlatformTestCase { public void testNativeDocumentTransactionUndoAndIndependentHandles() throws Exception { Path file = Files.createTempFile("designer-document-", ".fxml"); Files.writeString(file, "abcdef"); - com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess(getTestRootDisposable(), file.toRealPath().toString()); + com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess( + getTestRootDisposable(), file.toRealPath().toString()); try (var docs = new IntellijDocuments(getProject())) { var a = docs.open(file.toUri()).toCompletableFuture().get(); var b = docs.open(file.toUri()).toCompletableFuture().get(); @@ -48,7 +49,8 @@ public final class IntellijDocumentsTest extends BasePlatformTestCase { public void testExternalChangesDoNotLoseDraft() throws Exception { Path file = Files.createTempFile("designer-conflict-", ".fxml"); Files.writeString(file, "old"); - com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess(getTestRootDisposable(), file.toRealPath().toString()); + com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess( + getTestRootDisposable(), file.toRealPath().toString()); try (var docs = new IntellijDocuments(getProject())) { var d = docs.open(file.toUri()).toCompletableFuture().get(); var changed = @@ -69,4 +71,42 @@ public final class IntellijDocumentsTest extends BasePlatformTestCase { Files.deleteIfExists(file); } } + + public void testKeepsNativeDocumentOpenedThroughAlias() throws Exception { + Path directory = Files.createTempDirectory("designer-alias-"); + Path real = directory.resolve("real"); + Files.createDirectory(real); + Path alias = directory.resolve("alias"); + Files.createSymbolicLink(alias, real); + Path file = real.resolve("View.fxml"); + Files.writeString(file, "before"); + com.intellij.openapi.vfs.newvfs.impl.VfsRootAccess.allowRootAccess( + getTestRootDisposable(), directory.toRealPath().toString(), directory.toString()); + var virtual = + com.intellij.openapi.vfs.LocalFileSystem.getInstance() + .refreshAndFindFileByNioFile(alias.resolve("View.fxml")); + var nativeDocument = + com.intellij.openapi.fileEditor.FileDocumentManager.getInstance().getDocument(virtual); + try (var docs = new IntellijDocuments(getProject()); + var handle = docs.open(alias.resolve("View.fxml").toUri()).toCompletableFuture().get()) { + var before = handle.snapshot().toCompletableFuture().get(); + var after = + handle + .apply( + new EditBatch( + before.revision(), + "Alias edit", + List.of(new EditBatch.Replacement(0, before.text().length(), "after")))) + .toCompletableFuture() + .get(); + assertEquals("after", nativeDocument.getText()); + assertEquals(file.toRealPath().toUri(), after.document()); + handle.save(after.revision()).toCompletableFuture().get(); + } finally { + Files.deleteIfExists(alias); + Files.deleteIfExists(file); + Files.deleteIfExists(real); + Files.deleteIfExists(directory); + } + } } diff --git a/designer-kit-ui/src/main/java/com/oracle/javafx/scenebuilder/kit/editor/panel/inspector/editors/I18nStringEditor.java b/designer-kit-ui/src/main/java/com/oracle/javafx/scenebuilder/kit/editor/panel/inspector/editors/I18nStringEditor.java new file mode 100644 index 0000000..9395481 --- /dev/null +++ b/designer-kit-ui/src/main/java/com/oracle/javafx/scenebuilder/kit/editor/panel/inspector/editors/I18nStringEditor.java @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2016, Gluon and/or its affiliates. + * Copyright (c) 2012, 2014, Oracle and/or its affiliates. + * All rights reserved. Use is subject to license terms. + * + * This file is available and licensed under the following license: + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the distribution. + * - Neither the name of Oracle Corporation nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.oracle.javafx.scenebuilder.kit.editor.panel.inspector.editors; + +import com.oracle.javafx.scenebuilder.kit.i18n.I18N; +import com.oracle.javafx.scenebuilder.kit.metadata.property.ValuePropertyMetadata; +import com.oracle.javafx.scenebuilder.kit.metadata.util.PrefixedValue; + +import java.util.Set; + +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.MenuItem; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TextInputControl; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; + +/** + * String editor with I18n + multi-line handling. + * + * + */ +public class I18nStringEditor extends PropertyEditor { + + private static final String PERCENT_STR = "%"; //NOI18N + private TextInputControl textNode = new TextField(); + private HBox i18nHBox = null; + private EventHandler valueListener; + private final MenuItem i18nMenuItem = new MenuItem(); + private final String I18N_ON = I18N.getString("inspector.i18n.on"); + private final String I18N_OFF = I18N.getString("inspector.i18n.off"); + private final MenuItem multilineMenuItem = new MenuItem(); + private final String MULTI_LINE = I18N.getString("inspector.i18n.multiline"); + private final String SINGLE_LINE = I18N.getString("inspector.i18n.singleline"); + private boolean multiLineSupported = false; + // Specific states + private boolean i18nMode = false; + private boolean multiLineMode = false; + + public I18nStringEditor(ValuePropertyMetadata propMeta, Set> selectedClasses, boolean multiLineSupported) { + super(propMeta, selectedClasses); + initialize(multiLineSupported); + } + + private void initialize(boolean multiLineSupported) { + this.multiLineSupported = multiLineSupported; + valueListener = event -> { + userUpdateValueProperty(getValue()); + textNode.selectAll(); + }; + setTextEditorBehavior(this, textNode, valueListener); + + getMenu().getItems().add(i18nMenuItem); + getMenu().getItems().add(multilineMenuItem); + + i18nMenuItem.setOnAction(e -> { + if (!i18nMode) { + setValue(new PrefixedValue(PrefixedValue.Type.RESOURCE_KEY, I18N.getString("inspector.i18n.dummykey")).toString()); + } else { + setValue(""); //NOI18N + } + I18nStringEditor.this.getCommitListener().handle(null); + updateMenuItems(); + }); + multilineMenuItem.setOnAction(e -> { + if (!multiLineMode) { + switchToTextArea(); + } else { + switchToTextField(); + } + multiLineMode = !multiLineMode; + updateMenuItems(); + }); + + // Neitzel FX Designer: retain native JavaFX caret, drag and word selection. + } + + @Override + public Object getValue() { + String val = textNode.getText(); + if (i18nMode) { + val = new PrefixedValue(PrefixedValue.Type.RESOURCE_KEY, val).toString(); + } else { + val = EditorUtils.getPlainString(val); + } + return val; + } + + @Override + public void setValue(Object value) { + setValueGeneric(value); + if (isSetValueDone()) { + return; + } + + if (value == null) { + textNode.setText(null); + return; + } + assert value instanceof String; + String val = (String) value; + PrefixedValue prefixedValue = new PrefixedValue(val); + String suffix = prefixedValue.getSuffix(); + + // Handle i18n + if (prefixedValue.isResourceKey()) { + if (!i18nMode) { + wrapInHBox(); + i18nMode = true; + } + } else if (i18nMode) { + // no percent + i18nMode + unwrapHBox(); + i18nMode = false; + } + + // Handle multi-line + if (containsLineFeed(prefixedValue.toString())) { + if (i18nMode) { + // multi-line + i18n ==> set as i18n only + multiLineMode = false; + switchToTextField(); + } else { + if (!multiLineMode) { + multiLineMode = true; + switchToTextArea(); + } + } + } else { + // no line feed + if (multiLineMode) { + multiLineMode = false; + switchToTextField(); + } + } + + if (i18nMode) { + textNode.setText(suffix); + } else { + // We may have other special characters (@, $, ...) to display in the text field + textNode.setText(prefixedValue.toString()); + } + updateMenuItems(); + } + + public void reset(ValuePropertyMetadata propMeta, Set> selectedClasses, boolean multiLineSupported) { + super.reset(propMeta, selectedClasses); + this.multiLineSupported = multiLineSupported; + textNode.setPromptText(null); + } + + @Override + public Node getValueEditor() { + Node valueEditor; + if (i18nMode) { + valueEditor = i18nHBox; + } else { + valueEditor = textNode; + } + + return super.handleGenericModes(valueEditor); + } + + @Override + protected void valueIsIndeterminate() { + handleIndeterminate(textNode); + } + + protected void switchToTextArea() { + if (textNode instanceof TextArea) { + return; + } + // Move the node from TextField to TextArea + TextArea textArea = new TextArea(textNode.getText()); + setTextEditorBehavior(this, textArea, valueListener); + textArea.setPrefRowCount(5); + setLayoutFormat(LayoutFormat.SIMPLE_LINE_TOP); + if (textNode.getParent() != null) { + // textNode is already in scene graph + EditorUtils.replaceNode(textNode, textArea, getLayoutFormat()); + } + textNode = textArea; + } + + protected void switchToTextField() { + if (textNode instanceof TextField) { + return; + } + // Move the node from TextArea to TextField. + // The current text is compacted to a single line. + String val = textNode.getText().replace("\n", "");//NOI18N + TextField textField = new TextField(val); + setTextEditorBehavior(this, textField, valueListener); + setLayoutFormat(LayoutFormat.SIMPLE_LINE_CENTERED); + if (textNode.getParent() != null) { + // textNode is already in scene graph + EditorUtils.replaceNode(textNode, textField, getLayoutFormat()); + } + textNode = textField; + } + + private void wrapInHBox() { + i18nHBox = new HBox(); + i18nHBox.setAlignment(Pos.CENTER); + EditorUtils.replaceNode(textNode, i18nHBox, null); + Label percentLabel = new Label(PERCENT_STR); + percentLabel.getStyleClass().add("symbol-prefix"); //NOI18N + i18nHBox.getChildren().addAll(percentLabel, textNode); + HBox.setHgrow(percentLabel, Priority.NEVER); + // we have to set a small pref width for the text node else it will + // revert to it's API set pref width which is too wide + textNode.setPrefWidth(30.0); + HBox.setHgrow(textNode, Priority.ALWAYS); + } + + private void unwrapHBox() { + i18nHBox.getChildren().remove(textNode); + EditorUtils.replaceNode(i18nHBox, textNode, null); + } + + private static boolean containsLineFeed(String str) { + return str.contains("\n"); //NOI18N + } + + @Override + public void requestFocus() { + EditorUtils.doNextFrame(() -> textNode.requestFocus()); + } + + private void updateMenuItems() { + if (i18nMode) { + i18nMenuItem.setText(I18N_OFF); + multilineMenuItem.setDisable(true); + } else { + i18nMenuItem.setText(I18N_ON); + multilineMenuItem.setDisable(false); + } + + if (multiLineMode) { + multilineMenuItem.setText(SINGLE_LINE); + i18nMenuItem.setDisable(true); + } else { + multilineMenuItem.setText(MULTI_LINE); + i18nMenuItem.setDisable(false); + } + + if (!multiLineSupported) { + multilineMenuItem.setDisable(true); + } + } +} 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 new file mode 100644 index 0000000..8346ff4 --- /dev/null +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/DesignerPane.java @@ -0,0 +1,582 @@ +package de.neitzel.fxdesigner.kit; + +import com.oracle.javafx.scenebuilder.kit.editor.panel.content.ContentPanelController; +import com.oracle.javafx.scenebuilder.kit.editor.panel.hierarchy.HierarchyPanelController; +import com.oracle.javafx.scenebuilder.kit.editor.panel.inspector.InspectorPanelController; +import com.oracle.javafx.scenebuilder.kit.fxom.*; +import de.neitzel.fxdesigner.build.BuildRefresh; +import de.neitzel.fxdesigner.contracts.*; +import de.neitzel.fxdesigner.host.*; +import de.neitzel.fxdesigner.workflow.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import javafx.animation.*; +import javafx.application.Platform; +import javafx.scene.control.*; +import javafx.scene.image.*; +import javafx.scene.layout.*; +import javafx.util.Duration; + +/** One shared Kit UI. Both hosts own the authoritative DocumentSession and native history. */ +public final class DesignerPane extends BorderPane implements AutoCloseable { + private final HostServices host; + private final HostLaunch launch; + private final Path file; + private final KitView kit; + private final Label status = new Label("Opening document"); + private final TextArea source = new TextArea(), output = new TextArea(); + private final ComboBox examples = new ComboBox<>(); + private final SplitPane design; + private final Timeline poll; + private final PauseTransition typing = new PauseTransition(Duration.millis(250)), + resize = new PauseTransition(Duration.millis(350)); + private String submittedSource; + private DocumentSession document; + private DocumentSnapshot draft; + private FxmlProjection projection; + private DesignerWorkflow workflow; + private boolean loading, closed, pending, last; + private final java.util.concurrent.atomic.AtomicLong renderSequence = + new java.util.concurrent.atomic.AtomicLong(); + private AutoCloseable changes; + private final Map images = new HashMap<>(); + private BuildRefresh.Run buildRun; + + public DesignerPane(HostServices host, HostLaunch launch, Path file) throws Exception { + this.host = host; + this.launch = launch; + this.file = file; + Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); + kit = new KitView(host); + design = + new SplitPane( + new HierarchyPanelController(kit.editor()).getPanelRoot(), + new ContentPanelController(kit.editor()).getPanelRoot(), + new InspectorPanelController(kit.editor()).getPanelRoot()); + design.setDividerPositions(.18, .72); + workflow = + new DesignerWorkflow( + host, + launch.configuration(), + launch.context(), + launch.maven(), + launch.tools(), + this::log); + Path reactor = launch.configuration().getParent().toRealPath(); + changes = + host.documents() + .onChange( + uri -> { + if (Path.of(uri).startsWith(reactor)) { + renderSequence.incrementAndGet(); + workflow.cancel(); + } + }); + var bar = + new ToolBar( + button("Build and Refresh", () -> build(Map.of())), + button( + "Cancel", + () -> { + if (buildRun != null) buildRun.cancel(); + workflow.cancel(); + renderSequence.incrementAndGet(); + }), + examples, + button("Render drafts", this::render), + 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)); + examples.setConverter( + new javafx.util.StringConverter<>() { + public String toString(DesignerWorkflow.Example value) { + return value == null ? "Select example" : value.label(); + } + + public DesignerWorkflow.Example fromString(String value) { + return null; + } + }); + var tabs = new TabPane(); + var split = new SplitPane(source, design); + split.setDividerPositions(.3); + tabs.getTabs().add(new Tab("Source / Design", split)); + tabs.getTabs().getFirst().setClosable(false); + output.setEditable(false); + output.setPrefRowCount(5); + setTop(bar); + setCenter(tabs); + setBottom(new VBox(status, output)); + source + .textProperty() + .addListener( + (o, a, b) -> { + if (!loading) { + commitSource(); + workflow.cancel(); + renderSequence.incrementAndGet(); + } + }); + typing.setOnFinished(e -> commitSource()); + resize.setOnFinished(e -> render()); + poll = + new Timeline( + new KeyFrame( + Duration.millis(400), + e -> { + if (document != null + && !pending + && !typing.getStatus().equals(Animation.Status.RUNNING)) + document + .snapshot() + .whenComplete( + (d, error) -> + fx( + () -> { + if (error != null) report(error); + else if (draft == null || d.revision() != draft.revision()) + load(d); + })); + })); + poll.setCycleCount(Animation.INDEFINITE); + host.documents() + .open(file.toUri()) + .whenComplete( + (d, error) -> + fx( + () -> { + if (error != null) { + report(error); + return; + } + if (closed) { + d.close(); + return; + } + document = d; + d.snapshot().whenComplete(this::updated); + poll.play(); + })); + } + + private Button button(String text, Runnable action) { + var b = new Button(text); + b.setOnAction( + e -> { + try { + action.run(); + } catch (Throwable error) { + report(error); + } + }); + return b; + } + + private void fx(Runnable action) { + Platform.runLater( + () -> { + if (!closed) action.run(); + }); + } + + private void log(String text) { + fx( + () -> { + output.appendText(text + "\n"); + if (output.getLength() > 60000) output.deleteText(0, output.getLength() - 50000); + }); + } + + private void report(Throwable error) { + host.diagnostics() + .report( + new HostServices.Diagnostic( + "Designer", HostServices.Severity.ERROR, String.valueOf(error), Optional.empty())); + status.setText("Designer: " + error.getMessage()); + log(error.toString()); + } + + private void updated(DocumentSnapshot value, Throwable error) { + fx( + () -> { + pending = false; + if (error != null) { + submittedSource = null; + report(error); + return; + } + if (submittedSource != null && !source.getText().equals(submittedSource)) { + draft = value; + submittedSource = null; + commitSource(); + return; + } + submittedSource = null; + load(value); + }); + } + + private void load(DocumentSnapshot value) { + if (closed) return; + loading = true; + renderSequence.incrementAndGet(); + workflow.cancel(); + try { + draft = value; + if (!source.getText().equals(value.text())) source.setText(value.text()); + source.setEditable(value.writable()); + // End an inspector editing session before replacing FXOM (also after native undo). + if (getScene() != null + && getScene().getFocusOwner() instanceof TextInputControl text + && text != source) text.getParent().requestFocus(); + projection = new FxmlProjection(value.text()); + String projected = projection.project(); + kit.editor().setFxmlTextAndLocation(projected, file.toUri().toURL()); + design.setDisable(!value.writable()); + kit.editor() + .getFxomDocument() + .sceneGraphRevisionProperty() + .addListener((o, a, b) -> canvasChanged()); + for (String id : projection.components().keySet()) { + var region = proxy(id); + if (region != null) { + region.show( + images.get(id), images.containsKey(id) ? "Previous preview" : "Render preview"); + region + .widthProperty() + .addListener( + (o, a, b) -> { + if (!loading) resize.playFromStart(); + }); + region + .heightProperty() + .addListener( + (o, a, b) -> { + if (!loading) resize.playFromStart(); + }); + } + } + status.setText( + "Context " + + launch.context() + + " · document revision " + + value.revision() + + (value.dirty() ? " · unsaved" : "")); + } catch (Throwable error) { + design.setDisable(true); + report(error); + } finally { + loading = false; + } + } + + private SnapshotRegion proxy(String id) { + var object = new FXOMFxIdIndex(kit.editor().getFxomDocument()).lookup(id); + return object instanceof FXOMInstance instance + && instance.getSceneGraphObject() instanceof SnapshotRegion region + ? region + : null; + } + + private void replace(String text, String name) { + if (draft == null || pending) return; + pending = true; + document + .apply( + new EditBatch( + draft.revision(), + name, + List.of(new EditBatch.Replacement(0, draft.text().length(), text)))) + .whenComplete(this::updated); + } + + private void commitSource() { + if (draft != null && !pending && !source.getText().equals(draft.text())) { + submittedSource = source.getText(); + replace(submittedSource, "Edit FXML source"); + } + } + + private void canvasChanged() { + if (loading || pending || closed) return; + try { + replace( + projection.restore(kit.editor().getFxomDocument().getFxmlText(false)), + "Component layout / property edit"); + } catch (Throwable error) { + report(error); + load(draft); + } + } + + private void withDraft(Runnable action) { + if (document == null) return; + if (pending || !source.getText().equals(draft.text())) { + commitSource(); + status.setText("Finishing source edit; repeat the action when synchronized"); + return; + } + action.run(); + } + + private void history(boolean redo) { + withDraft(() -> (redo ? document.redo() : document.undo()).whenComplete(this::updated)); + } + + private void build(Map approvals) { + withDraft( + () -> { + renderSequence.incrementAndGet(); + status.setText("Building selected context…"); + buildRun = workflow.build(approvals); + buildRun + .completion() + .whenComplete( + (result, error) -> + fx( + () -> { + if (error != null) { + report(error); + return; + } + status.setText(result.message()); + if (result.status() == BuildRefresh.Status.NEEDS_SAVE) { + var alert = + new Alert( + Alert.AlertType.CONFIRMATION, + "Save these build inputs?\n" + + String.join( + "\n", + result.unsaved().stream() + .map(d -> d.document().toString()) + .toList()), + ButtonType.OK, + ButtonType.CANCEL); + alert.setHeaderText("Save affected files and build"); + if (alert.showAndWait().orElse(ButtonType.CANCEL) == ButtonType.OK) { + var approved = new HashMap(); + result + .unsaved() + .forEach(d -> approved.put(d.document(), d.revision())); + build(approved); + } + } else if (result.status() == BuildRefresh.Status.READY) + discover(false); + })); + }); + } + + public void discover(boolean useLast) { + withDraft( + () -> { + last = useLast; + status.setText( + useLast ? "Discovering explicitly retained build…" : "Discovering successful build…"); + workflow + .discover(useLast) + .whenComplete( + (entries, error) -> + fx( + () -> { + if (error != null) { + report(error); + return; + } + examples.getItems().setAll(entries); + if (!entries.isEmpty()) { + String preferred = + de.neitzel.fxdesigner.config.ConfigurationFiles.load( + launch.configuration()) + .contexts() + .get(launch.context()) + .preview() + .defaultExample(); + examples + .getSelectionModel() + .select( + entries.stream() + .filter(e -> e.id().equals(preferred)) + .findFirst() + .orElse(entries.getFirst())); + } + status.setText( + (last ? "Outdated build explicitly selected · " : "") + + entries.size() + + " examples"); + render(); + })); + }); + } + + public void render() { + withDraft( + () -> { + var selected = examples.getValue(); + if (selected == null || projection == null) return; + long ticket = renderSequence.incrementAndGet(); + host.documents() + .snapshots() + .whenComplete( + (dirty, error) -> + fx( + () -> { + if (error != null) { + report(error); + return; + } + if (ticket != renderSequence.get()) return; + var drafts = new LinkedHashMap(); + drafts.put(draft.document(), draft); + dirty.stream() + .filter( + d -> + d.document().toString().endsWith(".fxml") + && Path.of(d.document()) + .startsWith(launch.configuration().getParent())) + .forEach(d -> drafts.put(d.document(), d)); + renderComponents(List.copyOf(drafts.values()), selected.id(), ticket); + })); + }); + } + + private void renderComponents(List drafts, String example, long ticket) { + if (ticket != renderSequence.get() || closed) return; + var nodes = new LinkedHashMap(); + for (String id : projection.components().keySet()) { + var region = proxy(id); + if (region == null) continue; + int + width = + (int) + Math.max( + 1, + Math.min( + 1600, region.getWidth() > 0 ? region.getWidth() : region.prefWidth(-1))), + height = + (int) + Math.max( + 1, + Math.min( + 1200, + region.getHeight() > 0 ? region.getHeight() : region.prefHeight(-1))); + nodes.put(id, new DesignerWorkflow.Size(width, height)); + region.updating(); + } + if (nodes.isEmpty()) return; + workflow + .renderImages(last, example, nodes, drafts) + .whenComplete( + (frames, error) -> + fx( + () -> { + if (ticket != renderSequence.get()) return; + if (error != null) { + report(error); + return; + } + for (var frame : frames) { + int width = frame.width(), height = frame.height(); + var image = new WritableImage(width, height); + byte[] rgba = frame.rgba(); + int[] argb = new int[width * height]; + for (int i = 0; i < argb.length; i++) + argb[i] = + ((rgba[i * 4 + 3] & 255) << 24) + | ((rgba[i * 4] & 255) << 16) + | ((rgba[i * 4 + 1] & 255) << 8) + | (rgba[i * 4 + 2] & 255); + image + .getPixelWriter() + .setPixels( + 0, + 0, + width, + height, + PixelFormat.getIntArgbInstance(), + argb, + 0, + width); + images.put(frame.node(), image); + var region = proxy(frame.node()); + if (region != null) + region.show(image, last ? "Explicit retained build" : "Target runtime"); + } + status.setText( + "Rendered snapshot " + + frames.getFirst().snapshot() + + " · revision " + + draft.revision()); + })); + } + + private void openComponent() { + if (projection == null || projection.components().isEmpty()) return; + var choice = + new ChoiceDialog<>( + projection.components().keySet().iterator().next(), projection.components().keySet()); + choice.setHeaderText("Open Component in a separate editor"); + choice + .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; + }); + }); + } + + public CompletionStage flush() { + var result = new CompletableFuture(); + flushInto(result, System.nanoTime() + TimeUnit.SECONDS.toNanos(5)); + return result; + } + + private void flushInto(CompletableFuture result, long deadline) { + if (System.nanoTime() > deadline) { + result.completeExceptionally( + new IllegalStateException("Source edit could not be synchronized")); + return; + } + if (closed) { + result.completeExceptionally(new IllegalStateException("Editor closed")); + return; + } + if (!pending && draft != null && source.getText().equals(draft.text())) { + result.complete(null); + return; + } + commitSource(); + var wait = new PauseTransition(Duration.millis(50)); + wait.setOnFinished(e -> flushInto(result, deadline)); + wait.play(); + } + + public KitView kit() { + return kit; + } + + public void close() { + if (closed) return; + closed = true; + try { + changes.close(); + } catch (Exception ignored) { + } + poll.stop(); + typing.stop(); + resize.stop(); + workflow.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 new file mode 100644 index 0000000..1e4bb8b --- /dev/null +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/FxmlProjection.java @@ -0,0 +1,204 @@ +package de.neitzel.fxdesigner.kit; + +import java.io.*; +import java.util.*; +import javax.xml.parsers.*; +import javax.xml.transform.*; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.w3c.dom.*; + +/** + * Bounded scalar/layout projection. Original XML owns bindings, controllers and unknown metadata. + */ +public final class FxmlProjection { + private static final String FX = "http://javafx.com/fxml/1"; + private static final Set EDITABLE = + Set.of( + "caption", + "text", + "prefWidth", + "prefHeight", + "minWidth", + "minHeight", + "maxWidth", + "maxHeight", + "layoutX", + "layoutY", + "visible", + "managed", + "disable", + "opacity", + "alignment", + "spacing", + "hgap", + "vgap", + "GridPane.columnIndex", + "GridPane.rowIndex", + "GridPane.hgrow", + "GridPane.vgrow"); + private final Document original; + private Document baseline; + private final Map components = new LinkedHashMap<>(); + + public FxmlProjection(String text) throws Exception { + original = parse(text); + } + + static Document parse(String text) throws Exception { + var f = DocumentBuilderFactory.newInstance(); + f.setNamespaceAware(true); + f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + f.setXIncludeAware(false); + f.setExpandEntityReferences(false); + return f.newDocumentBuilder().parse(new org.xml.sax.InputSource(new StringReader(text))); + } + + static String xml(Document d) throws Exception { + var t = TransformerFactory.newInstance().newTransformer(); + var w = new StringWriter(); + t.transform(new DOMSource(d), new StreamResult(w)); + return w.toString(); + } + + public Map components() { + return Map.copyOf(components); + } + + public String project() throws Exception { + var d = (Document) original.cloneNode(true); + var imports = new HashMap(); + var remove = new ArrayList(); + for (Node n = d.getFirstChild(); n != null; n = n.getNextSibling()) + if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import")) { + String type = pi.getData().trim(); + if (!type.startsWith("javafx.")) { + if (type.endsWith(".*")) + throw new IOException("Application wildcard imports need an explicit Component import"); + imports.put(type.substring(type.lastIndexOf('.') + 1), type); + remove.add(n); + } + } + remove.forEach(d::removeChild); + d.insertBefore( + d.createProcessingInstruction("import", SnapshotRegion.class.getName()), + d.getDocumentElement()); + var definitions = d.getElementsByTagNameNS(FX, "define"); + while (definitions.getLength() > 0) { + Node n = definitions.item(0); + n.getParentNode().removeChild(n); + } + var nodes = d.getElementsByTagName("*"); + for (int i = 0; i < nodes.getLength(); i++) { + var e = (Element) nodes.item(i); + String tag = e.getTagName(); + if (FX.equals(e.getNamespaceURI())) + throw new IOException("This minimal projection does not support " + tag); + if (imports.containsKey(tag)) { + String id = e.getAttributeNS(FX, "id"); + if (id.isBlank()) throw new IOException("Component projection requires fx:id"); + components.put(id, imports.get(tag)); + if (e.hasChildNodes()) + throw new IOException("Inline Component content is outside this projection"); + d.renameNode(e, null, "SnapshotRegion"); + var attrs = e.getAttributes(); + for (int a = attrs.getLength() - 1; a >= 0; a--) { + var attr = (Attr) attrs.item(a); + if (!EDITABLE.contains(attr.getName()) && !attr.getName().equals("fx:id")) + e.removeAttributeNode(attr); + } + } else if (Character.isUpperCase(tag.charAt(0)) + && tag.contains(".") + && !tag.startsWith("javafx.")) + throw new IOException("Unsupported application element: " + tag); + e.removeAttributeNS(FX, "controller"); + var attrs = e.getAttributes(); + for (int a = attrs.getLength() - 1; a >= 0; a--) { + var attr = (Attr) attrs.item(a); + if (attr.getValue().startsWith("#") + || attr.getValue().startsWith("$") + || attr.getName().startsWith("on")) e.removeAttributeNode(attr); + } + } + baseline = (Document) d.cloneNode(true); + return xml(d); + } + + private static Map indexed(Document d) throws IOException { + var result = new LinkedHashMap(); + result.put("@root", d.getDocumentElement()); + var nodes = d.getElementsByTagName("*"); + for (int i = 0; i < nodes.getLength(); i++) { + var e = (Element) nodes.item(i); + String id = e.getAttributeNS(FX, "id"); + if (!id.isBlank() && result.putIfAbsent(id, e) != null) + throw new IOException("Duplicate fx:id"); + } + return result; + } + + private static java.util.List structure(Document document) { + var result = new java.util.ArrayList(); + var objects = new IdentityHashMap(); + var nodes = document.getElementsByTagName("*"); + for (int i = 0; i < nodes.getLength(); i++) { + var e = (Element) nodes.item(i); + String tag = e.getTagName(); + if (Character.isUpperCase(tag.charAt(0))) { + Node parent = e.getParentNode(); + while (parent != null && !objects.containsKey(parent)) parent = parent.getParentNode(); + var fixed = new TreeMap(); + if (e != document.getDocumentElement() && e.getAttributeNS(FX, "id").isBlank()) + for (int a = 0; a < e.getAttributes().getLength(); a++) { + var attr = e.getAttributes().item(a); + if (!attr.getNodeName().startsWith("xmlns")) + fixed.put(attr.getNodeName(), attr.getNodeValue()); + } + result.add( + tag + + ":" + + e.getAttributeNS(FX, "id") + + ":" + + objects.getOrDefault(parent, -1) + + ":" + + fixed); + objects.put(e, objects.size()); + } + } + return result; + } + + public String restore(String projected) throws Exception { + var changed = parse(projected); + var before = indexed(baseline); + var after = indexed(changed); + var output = (Document) original.cloneNode(true); + var targets = indexed(output); + if (!before.keySet().equals(after.keySet()) || !structure(baseline).equals(structure(changed))) + throw new IOException("Structural editing is outside this minimal projection"); + for (String id : before.keySet()) { + var attributes = new HashSet(); + for (Element element : List.of(before.get(id), after.get(id))) + for (int i = 0; i < element.getAttributes().getLength(); i++) + attributes.add(element.getAttributes().item(i).getNodeName()); + for (String attr : attributes) + if (!EDITABLE.contains(attr) + && !attr.startsWith("xmlns") + && !before.get(id).getAttribute(attr).equals(after.get(id).getAttribute(attr))) + throw new IOException("Edit " + attr + " in source; this projection does not support it"); + } + for (String id : before.keySet()) + for (String attr : EDITABLE) { + String old = before.get(id).getAttribute(attr), value = after.get(id).getAttribute(attr); + if (!old.equals(value)) { + var target = targets.get(id); + String originalValue = target.getAttribute(attr); + if (originalValue.startsWith("$") || originalValue.startsWith("#")) + throw new IOException("Bound attributes must be edited in source"); + if (value.isEmpty()) target.removeAttribute(attr); + else target.setAttribute(attr, value); + } + } + return xml(output); + } +} 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 new file mode 100644 index 0000000..fd24a48 --- /dev/null +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/SnapshotRegion.java @@ -0,0 +1,79 @@ +package de.neitzel.fxdesigner.kit; + +import javafx.beans.property.*; +import javafx.scene.control.Label; +import javafx.scene.image.*; +import javafx.scene.layout.Region; + +/** Host-owned pixels only; never loads an application control into the host runtime. */ +public final class SnapshotRegion extends Region { + private final StringProperty caption = new SimpleStringProperty(this, "caption", ""); + private final ObjectProperty alignment = + new SimpleObjectProperty<>(this, "alignment", javafx.geometry.Pos.TOP_LEFT); + + public javafx.geometry.Pos getAlignment() { + return alignment.get(); + } + + public void setAlignment(javafx.geometry.Pos value) { + alignment.set(value); + } + + public ObjectProperty alignmentProperty() { + return alignment; + } + + private final ImageView pixels = new ImageView(); + private final Label status = new Label("Render preview"); + + public SnapshotRegion() { + getChildren().addAll(pixels, status); + pixels.setMouseTransparent(true); + status.setMouseTransparent(true); + setStyle("-fx-background-color:#edf3f8;-fx-border-color:#6b879c"); + } + + public String getCaption() { + return caption.get(); + } + + public void setCaption(String value) { + caption.set(value); + } + + public StringProperty captionProperty() { + return caption; + } + + public void show(Image image, String message) { + pixels.setImage(image); + status.setText(message); + requestLayout(); + } + + public Image image() { + return pixels.getImage(); + } + + public String statusText() { + return status.getText(); + } + + public void updating() { + status.setText("Updating…"); + } + + protected void layoutChildren() { + pixels.setFitWidth(getWidth()); + pixels.setFitHeight(getHeight()); + status.resizeRelocate(4, Math.max(0, getHeight() - 24), Math.max(0, getWidth() - 8), 24); + } + + protected double computePrefWidth(double height) { + return 300; + } + + protected double computePrefHeight(double width) { + return 220; + } +} diff --git a/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/SourceOnlyPane.java b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/SourceOnlyPane.java new file mode 100644 index 0000000..e23ab1e --- /dev/null +++ b/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/SourceOnlyPane.java @@ -0,0 +1,104 @@ +package de.neitzel.fxdesigner.kit; + +import de.neitzel.fxdesigner.contracts.*; +import de.neitzel.fxdesigner.host.*; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.*; +import javafx.application.Platform; +import javafx.scene.control.*; +import javafx.scene.layout.*; + +/** Source editing stays available when standalone configuration cannot enable the designer. */ +public final class SourceOnlyPane extends BorderPane implements AutoCloseable { + private final TextArea source = new TextArea(); + private final Label status = new Label(); + private DocumentSession document; + private DocumentSnapshot baseline; + private boolean closed; + + public SourceOnlyPane(HostServices host, Path file, String reason) { + setTop(new Label("Designer unavailable: " + reason + " — correct local setup and reopen")); + setCenter(source); + var apply = new Button("Apply source"); + var save = new Button("Save"); + var undo = new Button("Undo"); + var redo = new Button("Redo"); + setBottom(new VBox(new ToolBar(apply, save, undo, redo), status)); + source.setEditable(false); + apply.setOnAction(e -> apply(false)); + save.setOnAction(e -> apply(true)); + undo.setOnAction( + e -> { + if (document != null) update(document.undo()); + }); + redo.setOnAction( + e -> { + if (document != null) update(document.redo()); + }); + host.documents() + .open(file.toUri()) + .whenComplete( + (d, error) -> + Platform.runLater( + () -> { + if (error != null) { + status.setText(error.getMessage()); + return; + } + if (closed) { + d.close(); + return; + } + document = d; + update(d.snapshot()); + })); + } + + private CompletionStage apply(boolean save) { + if (document == null || baseline == null) + return CompletableFuture.failedFuture(new IllegalStateException("Document not ready")); + source.setEditable(false); + var result = + source.getText().equals(baseline.text()) + ? document.snapshot() + : document.apply( + new EditBatch( + baseline.revision(), + "Edit FXML source", + List.of( + new EditBatch.Replacement(0, baseline.text().length(), source.getText())))); + if (save) result = result.thenCompose(value -> document.save(value.revision())); + update(result); + return result; + } + + public CompletionStage save() { + return apply(true).thenApply(value -> null); + } + + public boolean dirty() { + return baseline != null && (baseline.dirty() || !source.getText().equals(baseline.text())); + } + + private void update(CompletionStage result) { + result.whenComplete( + (value, error) -> + Platform.runLater( + () -> { + if (closed) return; + if (error != null) status.setText(String.valueOf(error.getMessage())); + else { + baseline = value; + source.setText(value.text()); + status.setText(value.dirty() ? "Unsaved draft" : "Saved"); + } + source.setEditable(baseline != null && baseline.writable()); + })); + } + + public void close() { + closed = true; + if (document != null) document.close(); + } +} diff --git a/designer-kit-ui/src/main/resources/META-INF/SCENEBUILDER-LICENSE b/designer-kit-ui/src/main/resources/META-INF/SCENEBUILDER-LICENSE new file mode 100644 index 0000000..7d67946 --- /dev/null +++ b/designer-kit-ui/src/main/resources/META-INF/SCENEBUILDER-LICENSE @@ -0,0 +1,31 @@ +Copyright (c) 2016, 2017 Gluon and/or its affiliates. +Copyright (c) 2012, 2014, Oracle and/or its affiliates. +All rights reserved. Use is subject to license terms. + +This file is available and licensed under the following license: + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the distribution. + - Neither the name of Oracle Corporation and Gluon nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/FxmlProjectionTest.java b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/FxmlProjectionTest.java new file mode 100644 index 0000000..9d96b4e --- /dev/null +++ b/designer-kit-ui/src/test/java/de/neitzel/fxdesigner/kit/FxmlProjectionTest.java @@ -0,0 +1,61 @@ +package de.neitzel.fxdesigner.kit; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class FxmlProjectionTest { + private static final String XML = + """ + + + + + + + + + """; + + @Test + void patchesOnlySupportedAttributesAndPreservesOriginalMetadata() throws Exception { + var projection = new FxmlProjection(XML); + String projected = projection.project(); + assertFalse(projected.contains("app.Controller")); + assertFalse(projected.contains("app.Card")); + assertFalse(projected.contains("app.Binding")); + String restored = + projection.restore(projected.replace("caption=\"before\"", "caption=\"after\"")); + assertTrue(restored.contains("caption=\"after\"")); + assertTrue(restored.contains("custom=\"preserve\"")); + assertTrue(restored.contains("app.Binding")); + assertTrue(restored.contains("app.Controller")); + assertTrue(restored.contains("retained")); + assertTrue(restored.contains("neitzel-model")); + } + + @Test + void rejectsExternalEntitiesAndExecutableFXML() throws Exception { + assertThrows( + Exception.class, + () -> new FxmlProjection("")); + assertThrows( + Exception.class, + () -> + new FxmlProjection( + "evil") + .project()); + } + + @Test + void rejectsStructuralEditsAndUnnamedComponents() throws Exception { + var projection = new FxmlProjection(XML); + String projected = projection.project(); + assertThrows( + Exception.class, + () -> projection.restore(projected.replace("fx:id=\"card\"", "fx:id=\"different\""))); + assertThrows( + Exception.class, () -> new FxmlProjection(XML.replace("fx:id=\"card\"", "")).project()); + } +} 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 new file mode 100644 index 0000000..e2dffce --- /dev/null +++ b/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/DesignerApplication.java @@ -0,0 +1,193 @@ +package de.neitzel.fxdesigner.standalone; + +import de.neitzel.fxdesigner.kit.DesignerPane; +import de.neitzel.fxdesigner.workflow.HostLaunch; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.stage.Stage; + +/** Development standalone entry: launch settings file and initial FXML path. */ +public final class DesignerApplication extends Application { + private HostLaunch launch; + private StandaloneHost host; + private int windows; + + @Override + public void start(Stage stage) throws Exception { + var args = getParameters().getRaw(); + if (args.size() != 2) + throw new IllegalArgumentException("Arguments: local-launch.properties View.fxml"); + try { + launch = HostLaunch.load(Path.of(args.get(0))); + open(stage, Path.of(args.get(1))); + } catch (Exception setup) { + if (host != null) host.close(); + host = + new StandaloneHost( + (name, role) -> CompletableFuture.failedFuture(setup), + location -> CompletableFuture.failedFuture(setup), + diagnostic -> System.err.println(diagnostic.message())); + var source = + new de.neitzel.fxdesigner.kit.SourceOnlyPane( + host.services(), Path.of(args.get(1)), String.valueOf(setup.getMessage())); + stage.setTitle("Neitzel FX Designer — source"); + stage.setScene(new Scene(source, 1000, 700)); + stage.setOnCloseRequest( + event -> { + if (!source.dirty()) return; + event.consume(); + var save = new javafx.scene.control.ButtonType("Save"); + var discard = new javafx.scene.control.ButtonType("Discard"); + var choice = + new javafx.scene.control.Alert( + javafx.scene.control.Alert.AlertType.CONFIRMATION, + "Unsaved FXML draft", + save, + discard, + javafx.scene.control.ButtonType.CANCEL) + .showAndWait() + .orElse(javafx.scene.control.ButtonType.CANCEL); + if (choice == discard) { + stage.setOnCloseRequest(null); + stage.close(); + } else if (choice == save) + source + .save() + .whenComplete( + (v, error) -> + javafx.application.Platform.runLater( + () -> { + if (error == null) { + stage.setOnCloseRequest(null); + stage.close(); + } + })); + }); + stage.setOnHidden( + event -> { + source.close(); + host.close(); + }); + stage.show(); + } + } + + private void open(Stage stage, Path file) throws Exception { + if (host == null) + host = + new StandaloneHost( + launch.runtimes(), + location -> { + var future = new CompletableFuture(); + javafx.application.Platform.runLater( + () -> { + try { + open(new Stage(), Path.of(location.document())); + future.complete(null); + } catch (Exception e) { + future.completeExceptionally(e); + } + }); + return future; + }, + d -> System.err.println(d.message())); + var pane = new DesignerPane(host.services(), launch, file); + stage.setTitle("Neitzel FX Designer — " + file.getFileName()); + stage.setScene(new Scene(pane, 1300, 850)); + windows++; + boolean[] closing = {false}; + stage.setOnCloseRequest( + event -> { + event.consume(); + if (closing[0]) return; + closing[0] = true; + pane.flush() + .thenCompose(v -> host.services().documents().unsaved()) + .whenComplete( + (drafts, error) -> + javafx.application.Platform.runLater( + () -> { + if (error != null) { + closing[0] = false; + new javafx.scene.control.Alert( + javafx.scene.control.Alert.AlertType.ERROR, + error.getMessage()) + .show(); + return; + } + if (windows > 1 || drafts.isEmpty()) { + stage.setOnCloseRequest(null); + stage.close(); + return; + } + var save = new javafx.scene.control.ButtonType("Save all"); + var discard = new javafx.scene.control.ButtonType("Discard drafts"); + var dialog = + new javafx.scene.control.Alert( + javafx.scene.control.Alert.AlertType.CONFIRMATION, + String.join( + "\n", + drafts.stream().map(d -> d.document().toString()).toList()), + save, + discard, + javafx.scene.control.ButtonType.CANCEL); + dialog.setHeaderText("Unsaved FXML drafts"); + var choice = + dialog.showAndWait().orElse(javafx.scene.control.ButtonType.CANCEL); + if (choice == javafx.scene.control.ButtonType.CANCEL) { + closing[0] = false; + return; + } + if (choice == discard) { + stage.setOnCloseRequest(null); + stage.close(); + return; + } + Thread.ofVirtual() + .start( + () -> { + try { + for (var draft : drafts) + try (var document = + host.services() + .documents() + .open(draft.document()) + .toCompletableFuture() + .get()) { + document + .save(draft.revision()) + .toCompletableFuture() + .get(); + } + javafx.application.Platform.runLater( + () -> { + stage.setOnCloseRequest(null); + stage.close(); + }); + } catch (Exception failure) { + javafx.application.Platform.runLater( + () -> { + closing[0] = false; + new javafx.scene.control.Alert( + javafx.scene.control.Alert.AlertType.ERROR, + "Save failed: " + failure.getMessage()) + .show(); + }); + } + }); + })); + }); + stage.setOnHidden( + e -> { + pane.close(); + if (--windows == 0) host.close(); + }); + stage.show(); + } + + public static void main(String[] args) { + launch(args); + } +} 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 3ce5f66..acaa24e 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 @@ -14,6 +14,14 @@ import java.util.concurrent.*; public final class FileDocuments implements HostServices.Documents, AutoCloseable { private final ExecutorService executor = Executors.newSingleThreadExecutor(Thread.ofVirtual().factory()); + private final java.util.concurrent.CopyOnWriteArrayList> + changes = new java.util.concurrent.CopyOnWriteArrayList<>(); + + public AutoCloseable onChange(java.util.function.Consumer listener) { + changes.add(listener); + return () -> changes.remove(listener); + } + private final Map documents = new HashMap<>(); private static final class State { @@ -55,6 +63,10 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl }); } + public CompletionStage> snapshots() { + return submit(() -> documents.values().stream().map(FileDocuments::snapshot).toList()); + } + public CompletionStage> unsaved() { return submit( () -> @@ -98,6 +110,7 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl s.redo.clear(); s.text = updated; s.revision++; + changes.forEach(listener -> listener.accept(s.file.toUri())); } return FileDocuments.snapshot(s); }); @@ -120,6 +133,7 @@ public final class FileDocuments implements HostServices.Documents, AutoCloseabl to.push(s.text); s.text = from.pop(); s.revision++; + changes.forEach(listener -> listener.accept(s.file.toUri())); } return FileDocuments.snapshot(s); }); diff --git a/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/Launcher.java b/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/Launcher.java new file mode 100644 index 0000000..c518c75 --- /dev/null +++ b/designer-standalone/src/main/java/de/neitzel/fxdesigner/standalone/Launcher.java @@ -0,0 +1,8 @@ +package de.neitzel.fxdesigner.standalone; + +/** Plain launcher supports the development classpath packaging of JavaFX. */ +public final class Launcher { + public static void main(String[] args) { + javafx.application.Application.launch(DesignerApplication.class, args); + } +} diff --git a/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/KitWorkflowSmoke.java b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/KitWorkflowSmoke.java new file mode 100644 index 0000000..633c28b --- /dev/null +++ b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/KitWorkflowSmoke.java @@ -0,0 +1,269 @@ +package de.neitzel.fxdesigner.standalone; + +import com.oracle.javafx.scenebuilder.kit.fxom.*; +import de.neitzel.fxdesigner.kit.*; +import de.neitzel.fxdesigner.workflow.*; +import java.nio.file.*; +import java.util.concurrent.*; +import javafx.application.Platform; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.stage.Stage; + +public final class KitWorkflowSmoke { + static DesignerPane pane; + static Stage stage; + static de.neitzel.fxdesigner.kit.SourceOnlyPane fallback; + + static T fx(Callable action) throws Exception { + var result = new CompletableFuture(); + Platform.runLater( + () -> { + try { + result.complete(action.call()); + } catch (Throwable e) { + result.completeExceptionally(e); + } + }); + return result.get(15, TimeUnit.SECONDS); + } + + static void click(String label) throws Exception { + fx( + () -> { + ((Button) + pane.lookupAll(".button").stream() + .filter(n -> n instanceof Button b && b.getText().equals(label)) + .findFirst() + .orElseThrow()) + .fire(); + return null; + }); + } + + static void waitText(de.neitzel.fxdesigner.host.DocumentSession doc, String text) + throws Exception { + long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(8); + while (System.nanoTime() < until) { + if (doc.snapshot().toCompletableFuture().get().text().contains(text)) return; + Thread.sleep(50); + } + throw new AssertionError("Document did not reach " + text); + } + + public static void main(String[] args) throws Exception { + var started = new CountDownLatch(1); + Platform.startup(started::countDown); + started.await(); + Platform.setImplicitExit(false); + var launch = HostLaunch.load(Path.of(args[0])); + try (var host = + new StandaloneHost( + launch.runtimes(), + l -> CompletableFuture.completedFuture(null), + d -> System.out.println(d.message()))) { + fx( + () -> { + pane = new DesignerPane(host.services(), launch, Path.of(args[1])); + stage = new Stage(); + stage.setTitle("Neitzel FX Designer — workflow qualification"); + stage.setScene(new Scene(pane, 1300, 850)); + stage.show(); + return null; + }); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20); + while (!fx(() -> pane.kit().editor().getFxomDocument() != null) + && System.nanoTime() < deadline) Thread.sleep(100); + fx( + () -> { + if (pane.kit().editor().getFxomDocument() == null) + throw new AssertionError("Kit projection unavailable"); + return null; + }); + click("Build and Refresh"); + deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(90); + boolean rendered = false; + while (System.nanoTime() < deadline) { + rendered = + fx( + () -> + java.util.List.of("card", "second").stream() + .allMatch( + id -> { + var obj = + new FXOMFxIdIndex(pane.kit().editor().getFxomDocument()) + .lookup(id); + return obj instanceof FXOMInstance instance + && instance.getSceneGraphObject() instanceof SnapshotRegion region + && region.image() != null + && region.statusText().equals("Target runtime"); + })); + if (rendered) break; + Thread.sleep(100); + } + if (!rendered) { + System.err.println( + fx( + () -> + pane.lookupAll(".label").stream() + .filter(n -> n instanceof Label) + .map(n -> ((Label) n).getText()) + .toList())); + throw new AssertionError("Shared Kit did not render target pixels"); + } + fx( + () -> { + var card = new FXOMFxIdIndex(pane.kit().editor().getFxomDocument()).lookup("card"); + pane.kit().editor().getSelection().select(card); + return null; + }); + Thread.sleep(400); + fx( + () -> { + var card = + (FXOMInstance) + new FXOMFxIdIndex(pane.kit().editor().getFxomDocument()).lookup("card"); + String caption = ((SnapshotRegion) card.getSceneGraphObject()).getCaption(); + var field = + (TextField) + pane.lookupAll(".text-field").stream() + .filter(n -> n instanceof TextField t && t.getText().equals(caption)) + .findFirst() + .orElseThrow(); + field.setText("alpha bravo charlie"); + pane.applyCss(); + pane.layout(); + var result = new StringBuilder(); + TextSelectionChecks.run(field, result); + System.out.print(result); + field.setText(caption); + field.fireEvent(new javafx.event.ActionEvent()); + return null; + }); + fx( + () -> { + var type = + com.oracle.javafx.scenebuilder.kit.editor.panel.inspector.editors.I18nStringEditor + .class; + if (!type.getProtectionDomain() + .getCodeSource() + .getLocation() + .toString() + .contains("designer-kit-ui")) + throw new AssertionError("Unpatched Kit editor loaded"); + var snapshot = pane.snapshot(null, null); + javax.imageio.ImageIO.write( + javafx.embed.swing.SwingFXUtils.fromFXImage(snapshot, null), + "png", + Path.of(args[2]).toFile()); + return null; + }); + String originalCaption = + fx( + () -> { + var editor = pane.kit().editor(); + var card = + (FXOMInstance) new FXOMFxIdIndex(editor.getFxomDocument()).lookup("card"); + var property = + (FXOMPropertyT) + card.getProperties() + .get( + new com.oracle.javafx.scenebuilder.kit.metadata.util.PropertyName( + "caption")); + String old = property.getValue(); + editor + .getJobManager() + .push( + new com.oracle.javafx.scenebuilder.kit.editor.job.Job(editor) { + public boolean isExecutable() { + return true; + } + + public String getDescription() { + return "Caption"; + } + + public void execute() { + set("Native canvas edit"); + } + + public void undo() { + set(old); + } + + public void redo() { + execute(); + } + + private void set(String value) { + var document = editor.getFxomDocument(); + document.beginUpdate(); + try { + property.setValue(value); + } finally { + document.endUpdate(); + } + } + }); + return old; + }); + try (var document = + host.services().documents().open(Path.of(args[1]).toUri()).toCompletableFuture().get()) { + waitText(document, "Native canvas edit"); + click("Undo"); + waitText(document, originalCaption); + click("Redo"); + waitText(document, "Native canvas edit"); + if (!document.snapshot().toCompletableFuture().get().text().contains("path=\"home\"")) + throw new AssertionError("Binding metadata lost"); + click("Save"); + long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!Files.readString(Path.of(args[1])).contains("Native canvas edit") + && System.nanoTime() < until) Thread.sleep(50); + if (!Files.readString(Path.of(args[1])).contains("Native canvas edit")) + throw new AssertionError("Host save missing"); + } + fx( + () -> { + pane.close(); + fallback = + new de.neitzel.fxdesigner.kit.SourceOnlyPane( + host.services(), Path.of(args[1]), "Unsupported configuration fixture"); + stage.setScene(new Scene(fallback, 1000, 700)); + return null; + }); + long readyUntil = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!fx(() -> ((TextArea) fallback.getCenter()).isEditable()) + && System.nanoTime() < readyUntil) Thread.sleep(50); + fx( + () -> { + var text = (TextArea) fallback.getCenter(); + text.appendText(""); + ((Button) + fallback.lookupAll(".button").stream() + .filter(n -> n instanceof Button b && b.getText().equals("Save")) + .findFirst() + .orElseThrow()) + .fire(); + return null; + }); + long savedUntil = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!Files.readString(Path.of(args[1])).contains("source remains editable") + && System.nanoTime() < savedUntil) Thread.sleep(50); + if (!Files.readString(Path.of(args[1])).contains("source remains editable")) + throw new AssertionError("Source fallback did not save"); + System.out.println( + "PASS native shared Kit: projection, Build and Refresh, worker pixels, selection, patched" + + " class and snapshot"); + } finally { + fx( + () -> { + if (pane != null) pane.close(); + if (fallback != null) fallback.close(); + if (stage != null) stage.close(); + return null; + }); + Platform.exit(); + } + } +} diff --git a/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/TextSelectionChecks.java b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/TextSelectionChecks.java new file mode 100644 index 0000000..13e66ad --- /dev/null +++ b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/TextSelectionChecks.java @@ -0,0 +1,90 @@ +package de.neitzel.fxdesigner.standalone; + +import javafx.event.Event; +import javafx.scene.control.TextInputControl; +import javafx.scene.input.*; +import javafx.scene.text.Text; + +/** Shared real-control mouse event assertions; native device checks are separate. */ +public final class TextSelectionChecks { + static void mouse( + TextInputControl t, + javafx.event.EventType type, + int count, + int index, + boolean down) { + Text text = (Text) t.lookup(".text"); + Text prefix = new Text(t.getText().substring(0, index)); + prefix.setFont(t.getFont()); + var point = + text.localToScene( + prefix.getLayoutBounds().getWidth() + 1, text.getLayoutBounds().getCenterY()); + var screen = t.getScene().getRoot().localToScreen(point); + Event.fireEvent( + t, + new MouseEvent( + type, + point.getX(), + point.getY(), + screen.getX(), + screen.getY(), + MouseButton.PRIMARY, + count, + false, + false, + false, + false, + down, + false, + false, + false, + false, + type != MouseEvent.MOUSE_DRAGGED, + new PickResult(t, point.getX(), point.getY()))); + } + + static void click(TextInputControl t, int count, int index) { + mouse(t, MouseEvent.MOUSE_PRESSED, count, index, true); + mouse(t, MouseEvent.MOUSE_RELEASED, count, index, false); + } + + public static void run(TextInputControl t, StringBuilder result) { + t.requestFocus(); + t.positionCaret(t.getLength()); + click(t, 1, 7); + int caret = t.getCaretPosition(); + boolean single = t.getSelection().getLength() == 0 && caret > 0 && caret < t.getLength(); + result.append((single ? "PASS" : "FAIL") + " single click: " + t.getSelection() + "\n"); + t.positionCaret(t.getLength()); + click(t, 1, 8); + click(t, 2, 8); + boolean word = "bravo".equals(t.getSelectedText()); + result.append((word ? "PASS" : "FAIL") + " double click: " + t.getSelectedText() + "\n"); + t.positionCaret(t.getLength()); + mouse(t, MouseEvent.MOUSE_PRESSED, 1, 7, true); + mouse(t, MouseEvent.MOUSE_DRAGGED, 1, 10, true); + mouse(t, MouseEvent.MOUSE_RELEASED, 1, 10, false); + boolean drag = + t.getSelection().getStart() > 0 + && t.getSelection().getEnd() < t.getLength() + && t.getSelection().getLength() > 0; + result.append((drag ? "PASS" : "FAIL") + " interior drag: " + t.getSelection() + "\n"); + t.positionCaret(0); + mouse(t, MouseEvent.MOUSE_PRESSED, 1, 10, true); + mouse(t, MouseEvent.MOUSE_DRAGGED, 1, 7, true); + mouse(t, MouseEvent.MOUSE_RELEASED, 1, 7, false); + boolean reverse = t.getAnchor() == 10 && t.getCaretPosition() == 7; + result.append( + (reverse ? "PASS" : "FAIL") + + " reverse drag: anchor=" + + t.getAnchor() + + " caret=" + + t.getCaretPosition() + + "\n"); + click(t, 3, 8); + boolean triple = t.getSelectedText().equals(t.getText()); + result.append((triple ? "PASS" : "FAIL") + " triple click retains select-all\n"); + if (!single || !word || !drag || !reverse || !triple) + throw new AssertionError("Kit must preserve native text selection"); + } +} diff --git a/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/WorkerOwnerProbe.java b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/WorkerOwnerProbe.java new file mode 100644 index 0000000..55f3672 --- /dev/null +++ b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/WorkerOwnerProbe.java @@ -0,0 +1,26 @@ +package de.neitzel.fxdesigner.standalone; + +import de.neitzel.fxdesigner.build.*; +import de.neitzel.fxdesigner.workflow.*; +import java.nio.file.*; +import java.util.*; + +/** Separate owner JVM for an actual abrupt-owner-death regression. */ +public final class WorkerOwnerProbe { + public static void main(String[] args) throws Exception { + Path root = Path.of(args[0]), fixture = Path.of(args[1]), pid = Path.of(args[2]); + var store = new SnapshotStore(fixture.resolve(".neitzel-fx-designer/snapshots")); + try (var worker = + new PreviewWorker( + new DraftOverlay(store.lease(store.state("fixture").lastSuccessful()), List.of()))) { + worker.start( + List.of( + root.resolve("designer-target-tools/target/designer-target-tools-0.1.0-SNAPSHOT.jar"), + root.resolve("designer-contracts/target/designer-contracts-0.1.0-SNAPSHOT.jar"), + root.resolve("designer-preview-api/target/designer-preview-api-0.1.0-SNAPSHOT.jar")), + s -> {}); + Files.writeString(pid, Long.toString(worker.pid())); + Thread.sleep(60000); + } + } +} diff --git a/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/WorkflowSmoke.java b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/WorkflowSmoke.java new file mode 100644 index 0000000..5367866 --- /dev/null +++ b/designer-standalone/src/test/java/de/neitzel/fxdesigner/standalone/WorkflowSmoke.java @@ -0,0 +1,47 @@ +package de.neitzel.fxdesigner.standalone; + +import de.neitzel.fxdesigner.build.*; +import de.neitzel.fxdesigner.contracts.*; +import de.neitzel.fxdesigner.host.*; +import de.neitzel.fxdesigner.workflow.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; + +public final class WorkflowSmoke { + static int checks; + + static void check(boolean value, String message) { + if (!value) throw new AssertionError(message); + checks++; + } + + static T await(CompletionStage stage) throws Exception { + return stage.toCompletableFuture().get(120, TimeUnit.SECONDS); + } + + public static void main(String[] args) throws Exception { + Path root = Path.of(args[0]), java = Path.of(args[1]), fixture = Path.of(args[2]); + String mode = args[3]; + var runtimes = + new ConfiguredRuntimes( + Map.of( + new ConfiguredRuntimes.Key("java21", HostServices.RuntimeRole.BUILD), + new HostServices.RuntimeInstallation(java, 21), + new ConfiguredRuntimes.Key("java21", HostServices.RuntimeRole.TARGET), + new HostServices.RuntimeInstallation(java, 21))); + var tools = + List.of( + root.resolve("designer-target-tools/target/designer-target-tools-0.1.0-SNAPSHOT.jar"), + root.resolve("designer-contracts/target/designer-contracts-0.1.0-SNAPSHOT.jar"), + root.resolve("designer-preview-api/target/designer-preview-api-0.1.0-SNAPSHOT.jar")); + try (var host = + new StandaloneHost( + runtimes, + l -> CompletableFuture.completedFuture(null), + d -> System.out.println(d.message()))) { + checks = new ConnectedWorkflowContract().run(host.services(), root, fixture); + } + System.out.println("PASS connected " + mode + " " + checks); + } +} 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 8dc4fb4..ea8e804 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 @@ -169,6 +169,10 @@ public final class TargetPreview implements AutoCloseable { /** Raw, row-major, straight-alpha RGBA8 matching the wire contract. Must run on FX. */ public byte[] render(int width, int height) { + return renderNode("", width, height); + } + + public byte[] renderNode(String nodeId, int width, int height) { fx(); if (closed || !ready.isDone() || ready.isCompletedExceptionally()) throw new IllegalStateException("Preview not ready"); @@ -179,10 +183,18 @@ public final class TargetPreview implements AutoCloseable { || (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); + 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); - parameters.setViewport(new Rectangle2D(0, 0, width, height)); - var image = view.getRoot().snapshot(parameters, new WritableImage(width, height)); + 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++) 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 new file mode 100644 index 0000000..3a508fc --- /dev/null +++ b/designer-target-tools/src/main/java/de/neitzel/fxdesigner/target/WorkerMain.java @@ -0,0 +1,197 @@ +package de.neitzel.fxdesigner.target; + +import static de.neitzel.fxdesigner.protocol.WireProtocol.*; + +import de.neitzel.fxdesigner.protocol.*; +import java.io.*; +import java.net.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import javafx.application.Platform; + +/** Target-only entry point. Stdin is the reserved owner channel, never application input. */ +public final class WorkerMain { + private static volatile TargetPreview preview; + private static volatile boolean fx; + + private static synchronized void send(Socket socket, Message message) throws IOException { + FrameCodec.write(socket.getOutputStream(), message); + socket.getOutputStream().flush(); + } + + private static void stop() { + Thread.ofPlatform() + .daemon() + .start( + () -> { + try { + Thread.sleep(1500); + } catch (InterruptedException ignored) { + } + ProcessHandle.current().descendants().forEach(ProcessHandle::destroyForcibly); + Runtime.getRuntime().halt(0); + }); + if (preview != null) preview.close(); + if (fx) Platform.runLater(Platform::exit); + } + + public static void main(String[] args) throws Exception { + var owner = new DataInputStream(System.in); + String token = owner.readUTF(); + System.setIn(InputStream.nullInputStream()); + Thread.ofPlatform() + .daemon() + .name("designer-owner-lifetime") + .start( + () -> { + try { + while (owner.read() != -1) {} + } catch (IOException ignored) { + } + stop(); + }); + try (var socket = new Socket(InetAddress.getLoopbackAddress(), Integer.parseInt(args[0]))) { + socket.setSoTimeout(15000); + String session = args[1]; + send( + socket, + new Hello( + session, + ProcessHandle.current().pid(), + token, + new Version(1, 0), + new Version(1, 0), + new Version(1, 0), + Set.of("discover-v1", "render-rgba-v1"), + Set.of())); + var welcome = FrameCodec.read(socket.getInputStream()); + if (!(welcome instanceof Welcome w) || !session.equals(w.session()) || w.wire().major() != 1) + throw new IOException("Invalid welcome"); + socket.setSoTimeout(0); + boolean rendered = false; + String activeExample = null; + while (true) { + var message = FrameCodec.read(socket.getInputStream()); + if (!(message instanceof Packet p) || !p.identity().session().equals(session)) + throw new IOException("Invalid request"); + if (p.kind() == Kind.CANCEL) { + stop(); + return; + } + if (p.kind() != Kind.REQUEST || !w.operations().contains(p.operation())) + throw new IOException("Unnegotiated operation"); + try { + var loader = WorkerMain.class.getClassLoader(); + if (p.operation() == Operation.DISCOVER) { + if (p.payload().length != 0) throw new IOException("Discovery payload must be empty"); + var entries = ExampleCatalog.discover(loader); + var data = new TreeMap(); + data.put("format", "1"); + data.put("count", Integer.toString(entries.size())); + for (int i = 0; i < entries.size(); i++) { + var d = entries.get(i); + String prefix = "example." + i + "."; + data.put(prefix + "id", d.id()); + data.put(prefix + "label", d.label()); + data.put(prefix + "modelType", d.modelType()); + data.put(prefix + "providerClass", d.providerClass()); + data.put(prefix + "viewResource", d.viewResource()); + data.put(prefix + "componentPackage", d.componentPackage()); + } + send( + socket, + new Packet(Kind.RESULT, p.operation(), p.identity(), PreviewMessages.encode(data))); + } else { + + var data = PreviewMessages.decode(p.payload()); + if (!data.keySet().equals(Set.of("example", "node"))) + throw new IOException("Invalid render metadata"); + if (rendered && !data.get("example").equals(activeExample)) + throw new IOException("Example changed; start a fresh worker"); + if (!rendered) { + rendered = true; + activeExample = data.get("example"); + var descriptor = + ExampleCatalog.discover(loader).stream() + .filter(d -> d.id().equals(data.get("example"))) + .findFirst() + .orElseThrow(); + var started = new CountDownLatch(1); + Platform.startup( + () -> { + Platform.setImplicitExit(false); + started.countDown(); + }); + fx = true; + if (!started.await(10, TimeUnit.SECONDS)) throw new IOException("FX startup timeout"); + preview = TargetPreview.open(descriptor, loader, Duration.ofSeconds(20)); + } + preview + .ready() + .whenComplete( + (ready, error) -> { + if (error != null) { + error(socket, p, error); + return; + } + Platform.runLater( + () -> { + try { + send( + socket, + new Packet( + Kind.RESULT, + p.operation(), + p.identity(), + ready.renderNode( + data.get("node"), + p.identity().width(), + p.identity().height()))); + } catch (Throwable e) { + error(socket, p, e); + } + }); + }); + } + } catch (Throwable e) { + error(socket, p, e); + } + } + } catch (EOFException ignored) { + } finally { + stop(); + } + } + + private static void error(Socket socket, Packet p, Throwable error) { + try { + var description = new StringBuilder(); + var visited = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap()); + for (Throwable cause = error; + cause != null && visited.add(cause) && visited.size() <= 8; + cause = cause.getCause()) { + if (!description.isEmpty()) description.append("\nCaused by: "); + description + .append( + cause instanceof PreviewFailure failure + ? failure.code() + : cause.getClass().getSimpleName()) + .append(": ") + .append(cause.getMessage()); + } + String detail = description.toString(); + if (detail.length() > 8000) detail = detail.substring(0, 8000); + send( + socket, + new Packet( + Kind.ERROR, + p.operation(), + p.identity(), + ("LOAD_FAILED\n" + detail).getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (IOException ignored) { + stop(); + } + } +} diff --git a/docs/build-and-refresh.md b/docs/build-and-refresh.md index 461d681..1f611b3 100644 --- a/docs/build-and-refresh.md +++ b/docs/build-and-refresh.md @@ -67,7 +67,7 @@ Starting a build marks previous results outdated. A failed, cancelled or invalid A lease supplies ordered private runtime paths and one manifest/build identity for discovery and rendering. Each lease holds a filesystem lock in a unique ticket; eviction checks those locks across threads, store instances and processes. Published last-successful snapshots are retained even without a lease. Closing a lease permits eviction of superseded snapshots. Process death releases its OS lock; abandoned tickets and staging directories are recoverable. Normal run workspaces are removed when the run finishes; abandoned private workspaces after a host crash are ignored, not activated, and may be removed when no build process owns them. There is no automatic age/size quota yet. -Cancellation stops the owned Maven process and observed descendants through the shared process runner. Capture checks cancellation between files. Cancellation after the publication commit point can complete as READY; it does not retroactively delete a successfully published build. The caller must keep the lease alive for the full worker lifetime and release it after worker shutdown; worker integration is a later gate. +Cancellation stops the owned Maven process and observed descendants through the shared process runner. Capture checks cancellation between files. Cancellation after the publication commit point can complete as READY; it does not retroactively delete a successfully published build. The caller must keep the lease alive for the full worker lifetime and release it after worker shutdown; the [connected workflow](first-connected-workflow.md) now enforces this lifetime. ## Validation @@ -77,4 +77,4 @@ Unit/integration regressions additionally cover exact save approval, FXML draft Acceptance on 2026-11-12: **45 tests passed** (41 Maven, four IntelliJ), with no failures/errors/skips, plus **14 real Maven/Java 21 smoke checks**. The full clean bootstrap command also passed artifact identity, bytecode/dependency isolation, target linkage, plugin packaging and documentation checks. The previously documented intentional IntelliJ until-build warning remains. -The next bootstrap step validates the typed preview API and target adapter against real NeitzelLib classpath/module-path fixtures. Setup UI, worker transport, discovery/render restart, draft overlays and the connected two-host workflow remain later gates. +The [target adapter](preview-target-fixtures.md) and [connected two-host workflow](first-connected-workflow.md) are now implemented. Work package 7 adds captured source roots and allows changed saved FXML only when the exact resource is supplied by a private overlay; all other freshness checks remain in force. The historical test count above belongs to work package 5. diff --git a/docs/configuration-and-protocol.md b/docs/configuration-and-protocol.md index ade5087..e6f2fc8 100644 --- a/docs/configuration-and-protocol.md +++ b/docs/configuration-and-protocol.md @@ -1,5 +1,7 @@ # Executable configuration and compatibility +Implementation history: this page records its original bootstrap work package. The [first connected workflow](first-connected-workflow.md) now supplies worker transport, draft overlays and runnable minimal hosts; historical test counts below remain scoped to that earlier step. + Bootstrap work package 4 supplies the packaged schemas, shared configuration/context/runtime implementation, and a draft wire codec/session validator. Both host adapters use the same `HostServices` interface; the same configuration-failure/editing contract runs against standalone files and real IntelliJ Documents. No configuration check changes document writability or closes a draft. ## Configuration 1.0 diff --git a/docs/first-connected-workflow.md b/docs/first-connected-workflow.md new file mode 100644 index 0000000..35f5ebc --- /dev/null +++ b/docs/first-connected-workflow.md @@ -0,0 +1,101 @@ +# First connected workflow in both minimal hosts + +Bootstrap work package 7 connects the existing build/snapshot services to real target workers and the same Kit UI in standalone and IntelliJ. This is a runnable development milestone, with bounded scalar/layout projection, not the complete designer product. + +## Flow and ownership + +```mermaid +sequenceDiagram + participant UI as Shared Kit UI + participant Host as Standalone / IntelliJ documents + participant Core as DesignerWorkflow + participant Build as BuildRefresh / SnapshotStore + participant Worker as Java 21 target JVM + UI->>Core: Build and Refresh + Core->>Host: Inventory build drafts / exact save approval + Core->>Build: Scoped Maven build and immutable publication + UI->>Core: Discover successful build + Core->>Build: Acquire verified snapshot lease + Core->>Worker: Authenticated loopback discovery + Worker-->>UI: Resource-only example descriptors + UI->>Host: Capture parent and known Component drafts + UI->>Core: Render selected example and Component sizes + Core->>Build: Lease same build identity + Core->>Core: Private FXML overlay and tooling copies + Core->>Worker: New process, one loaded parent / Model graph + Worker-->>Core: Correlated RGBA images + Core->>Host: Recheck document revisions + Core-->>UI: Images if request generation remains current + UI->>Host: Canvas edit as one native document command + Core->>Worker: Close owner channel / bounded termination + Core->>Build: Release lease after worker shutdown +``` + +A render batch shares one target View, Model graph and configured services across all Component images. A later draft/size/example refresh uses a fresh process. No application Controller, Component or provider is loaded in the host. Kit receives host-owned `SnapshotRegion` objects. + +Source remains authoritative. The projection preserves original Controllers, bindings, processing instructions, comments and unrepresented attributes outside Kit. Supported scalar/layout edits on the root or identified nodes become one host document transaction; structural edits and unsupported attributes are rejected. Metadata is preserved semantically, although XML whitespace/serialization may change. Native document undo/redo reloads the projection. + +## Transport, overlays and lifetime + +`PreviewWorker` launches the configured target JDK, using the captured runtime and private copies of the three target tooling JARs. The application runtime never points at original mutable Maven outputs. Both classpath and boot-module-layer launches are implemented. Module launches deduplicate repeated module artifacts, retain configured explicit reads/opens, require the selected entry module to exist, and do not invent broad access fixes. + +Each launch gets a random token delivered through a reserved stdin pipe, not command-line arguments or logs. The worker replaces application stdin with an empty stream and watches the original owner pipe independently of FX responsiveness. HELLO validates token, session, launched PID, versions and required capabilities on a loopback socket. Stdout/stderr are bounded presentation logs, separate from binary framing. Each request/result includes context, build, revisions and size through the existing protocol validator. + +The host applies handshake/read deadlines and an overall 45-second worker deadline. Closing/cancelling the owner closes the socket and lifetime pipe, then forces termination if needed. Worker-side owner loss attempts cleanup and halts after a bounded grace period, independently of blocked FX. Abrupt owner death is tested in a separate process. Cleanup callbacks remain cooperative; they cannot be guaranteed after forced death. + +FXML overlays contain at most 128 drafts, each at most 1 MiB. Only affected class directories/JARs are privately copied; unchanged dependencies remain leased immutable snapshot files. An overlaid JAR with signature metadata is rejected. Resources must map unambiguously to captured source roots and exist in the runtime. The store verifies that overlays never alter the published snapshot. + +Saved FXML can differ from captured inputs **only when that exact file is supplied by the overlay**. Java/POM/settings changes and uncovered resource changes still invalidate current-build selection. Dirty and clean known/open Component drafts are captured, so saving an open Component does not require Java recompilation. A failed build still requires explicit **Preview last successful**. + +Images retain the existing 16 MiB per-frame limit; a batch additionally caps aggregate pixels at 64 MiB. Document-change notifications invalidate queued UI results immediately; revision checks and generation checks reject stale responses. The previous image stays visible during asynchronous updates. Snapshot viewports use parent coordinates, as required by [JavaFX SnapshotParameters](https://openjfx.io/javadoc/26/javafx.graphics/javafx/scene/SnapshotParameters.html#setViewport(javafx.geometry.Rectangle2D)). + +## Minimal host UI + +Both hosts show the shared source view, Kit hierarchy/canvas/inspector, build output, example selection and actions for build, cancellation, rendering, explicit retained-build preview, save, undo/redo and opening Component source. + +- Standalone owns shared file drafts across its windows. Closing the last window offers save/discard/cancel for unsaved drafts. Invalid setup opens a source-only editor with save and undo/redo. +- IntelliJ registers a separate **Neitzel Designer** editor after the normal editor. It uses native Documents and commands, including asynchronous edits from FX. Setup failures leave the regular source editor available. +- The IntelliJ gate checks the qualified SDK, disabled `embed.scene.builder` setting, bundled-provider refusal and absence of preloaded Glass before initialization. It revokes the designer if the registry setting changes. JavaFX/FXML language support stays enabled. +- The narrow Kit 26 I18nStringEditor selection patch is shared by both hosts. Its source/license provenance is in [third-party notices](../THIRD-PARTY-NOTICES.md). + +The IDE adapter retains the actual VirtualFile/Document opened through a path alias while using canonical paths for snapshot identities. This avoids a second document for macOS `/var` versus `/private/var` aliases. + +## Run and verify + +First run the full build command in [README](../README.md). The development launchers use that installed build and the verified NeitzelLib snapshot: + +```sh +python3 scripts/run-fixture-host.py standalone --java-home "$JAVA_HOME" --target-java-home "$TARGET_JAVA_HOME" +python3 scripts/run-fixture-host.py intellij --java-home "$JAVA_HOME" --target-java-home "$TARGET_JAVA_HOME" --idea-home "$IDEA_HOME" +``` + +Add `--mode modulepath` for the named-module application. Each launch makes a fresh disposable fixture and prints its location/PID. The IntelliJ launcher uses a private config/system/plugins directory with bundled embedding disabled; it does not alter the user's normal IDE settings. Select **Neitzel Designer**, then **Build and Refresh**. + +Machine-local `.neitzel-fx-designer/host.properties` explicitly selects configuration, context, runtime-settings file, fallback Maven executable and target tooling JAR paths. Relative paths resolve from that file. Multiple application contexts require an explicit context value; the minimal setup has no configuration wizard. These local files are not application configuration or published artifacts. + +Automated gates: + +```sh +python3 scripts/check-connected-workflow.py --target-java-home "$TARGET_JAVA_HOME" --idea-home "$IDEA_HOME" +python3 scripts/check-ide-host.py --java-home "$JAVA_HOME" --target-java-home "$TARGET_JAVA_HOME" --idea-home "$IDEA_HOME" +``` + +The first is included in `check-bootstrap.py`: real Maven builds, socket discovery/rendering, parent/Component overlays, shared render batches, source navigation, stale responses, saved-FXML reuse, native document undo/redo, invalid FXML and cancellation run through standalone and actual IntelliJ document adapters. Native standalone Kit windows verify both application layouts, inspector text selection, canvas commits, binding preservation, undo/redo, save and source-only fallback. A separate owner process verifies worker death after an abrupt shutdown. + +The second launches real isolated IntelliJ instances with a **test-only companion plugin**, excluded from the delivered plugin ZIP. It verifies editor registration, the shared UI, build/discovery/render, native document edits and undo/redo, then captures screenshots and hashes loaded OpenJFX natives. Evidence is written under `target/connected-workflow/` and `target/ide-workflow/`. + +## Qualification result — 2026-11-12 + +The clean bootstrap command passed **56 tests** (50 Maven, six IntelliJ), zero failures/errors/skips; **346 assertions across 24 target JVMs**; **14 Build and Refresh smoke checks**; and **64 connected workflow assertions** across both host document adapters and both application layouts. Native standalone Kit windows passed the five real inspector mouse-selection cases, build/render, canvas edit, undo/redo, save and source-only fallback for each layout. Abrupt owner death terminated its worker. + +The separate real-IDE command passed for **classpath and module-path** applications on IU 263.7307.200. Both registered editors completed Build and Refresh, rendered both Components, committed Kit edits to the native Document, performed undo/redo and rendered drafts. Screenshots were inspected. Both processes reported JavaFX **26+27**, exactly one Glass library, and loaded OpenJFX native hashes matching the JavaFX 26 artifacts. Glass SHA-256: `8fe9cc20e424b078ecaeb01ad6d72436439fb2b66d48dd094fa5d262a65d546a`. Disposable IDEs were terminated after verification. + +This work also fixes two issues exposed by the real registered editor: native edits now use IntelliJ's non-modal write-safe scheduling, and canonical snapshot paths retain the original alias-opened VirtualFile/Document. Artifact identity, dependency/bytecode gates, plugin ZIP checks and documentation validation passed. The intentional IntelliJ until-build restriction still produces its previously documented configuration warning. + +## Limits and next stage + +The qualified platform is macOS arm64, IntelliJ IU 263.7307.200, JavaFX/Kit 26 in hosts and the recorded Java 21 / JavaFX 21.0.7 target. This is not a Plugin Verifier result or wider OS/SDK/runtime qualification. + +Canvas support is intentionally bounded: explicit Component imports with fx:id, the tested Caption/alignment/layout attributes, and scalar/layout edits on identified standard nodes. fx:root/include/script, inline Component content, wildcard application imports, arbitrary structural edits and complete binding/generation editing retain source editing and later projection gates. Opening an fx:root Component currently gives source editing; its parent can preview those drafts. Separate interactive preview controls, general Component authoring, handoff, accessibility, remote IDEs, installers and product release packaging remain later roadmap work. + +The first workflow is now connected; the next roadmap stage is **Establish authoritative documents and generalize the Kit projection**, followed by richer Component preview and binding authoring. This milestone does not establish a corrected public NeitzelLib release or freeze a public preview API. diff --git a/docs/initial-project-contract.md b/docs/initial-project-contract.md index 38a17c1..69a3a12 100644 --- a/docs/initial-project-contract.md +++ b/docs/initial-project-contract.md @@ -84,7 +84,7 @@ Local settings hold installed JDK mappings, absolute machine paths and remembere ## Build and snapshot contract -Work package 5 now implements the shared [Build and Refresh workflow](build-and-refresh.md), including its validation and explicitly bounded input closure. Worker activation and UI wiring remain work package 7. +Work package 5 now implements the shared [Build and Refresh workflow](build-and-refresh.md), including its validation and explicitly bounded input closure. Worker activation and UI wiring are implemented in the [first connected workflow](first-connected-workflow.md). 1. Resolve the selected context and required reactor dependencies, including configured preview sources. Prefer the Maven wrapper; expose the plan, command/output and cancellation. Avoid unrelated applications unless dependency resolution requires them. 2. Identify known unsaved Java, Maven and designer-configuration inputs affecting the build. Offer **Save affected files and build** or **Cancel**, listing the affected files. Do not implicitly build older disk versions. Account for IDE autosave; another process's unsaved buffers are not observable. @@ -133,4 +133,4 @@ Accept the first milestone only when both minimal hosts use the same core/UI/fix - Listing examples performs no Model/Controller construction; selecting an example produces fresh state and honors readiness/cleanup. - Console output cannot corrupt worker messages; owner death, cancellation and timeout terminate workers. -These are acceptance requirements, not newly passed tests. Broader product behavior remains governed by the existing roadmap. +These acceptance requirements are covered by the combined work-package suites; the [connected-workflow report](first-connected-workflow.md) records the qualified platforms and bounded UI scope. Broader product behavior remains governed by the existing roadmap. diff --git a/docs/module-host-boundaries.md b/docs/module-host-boundaries.md index 383dd78..38b6fe2 100644 --- a/docs/module-host-boundaries.md +++ b/docs/module-host-boundaries.md @@ -1,5 +1,7 @@ # Buildable module and host-service boundaries +Implementation history: this page records its original bootstrap work package. The [first connected workflow](first-connected-workflow.md) now supplies worker transport, draft overlays and runnable minimal hosts; historical test counts below remain scoped to that earlier step. + Work package 3 implements a buildable service foundation. It does not register an IntelliJ editor, launch a standalone designer, initialize JavaFX inside IntelliJ, or implement target discovery/rendering. The earlier prototypes remain separate evidence. ## Artifacts diff --git a/docs/preview-target-fixtures.md b/docs/preview-target-fixtures.md index 43de168..64e256b 100644 --- a/docs/preview-target-fixtures.md +++ b/docs/preview-target-fixtures.md @@ -1,5 +1,7 @@ # Preview API and target adapter qualification +Implementation history: this page records its original bootstrap work package. The [first connected workflow](first-connected-workflow.md) now supplies worker transport, draft overlays and runnable minimal hosts; historical test counts below remain scoped to that earlier step. + Work package 6 is implemented against the [verified NeitzelLib development artifact](runtime/neitzellib-baseline.md), Java 21 and application JavaFX 21.0.7. This qualifies target-side execution; it does not connect the designer UI, snapshot activation or worker transport. Those remain work package 7. ## Discovery and construction diff --git a/docs/repository-bootstrap-plan.md b/docs/repository-bootstrap-plan.md index 9ac346f..89beeb5 100644 --- a/docs/repository-bootstrap-plan.md +++ b/docs/repository-bootstrap-plan.md @@ -1,6 +1,6 @@ # NeitzelFXDesigner repository bootstrap plan -Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work package 3 implements the module/service foundation; work package 4 implements configuration and protocol validation; work package 5 implements Build and Refresh with immutable snapshots; work package 6 validates the preview API and target adapter with real fixtures; work package 7 remains planned. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release. +Status: repository foundation implemented by the initial commit; work package 2 has a verified development artifact; work package 3 implements the module/service foundation; work package 4 implements configuration and protocol validation; work package 5 implements Build and Refresh with immutable snapshots; work package 6 validates the preview API and target adapter with real fixtures; work package 7 connects the first workflow in both minimal hosts. Consolidated 2026-11-12. Scope is the ordered implementation start for the [accepted initial contract](initial-project-contract.md); the initial commit creates the foundation only and does not publish a library release. ## Starting-point evidence before bootstrap @@ -58,7 +58,7 @@ Build shared libraries/fixtures using the Maven structure; verify and pin the ac ### 4. Make configuration and compatibility executable -Implemented: packaged schemas, profile-qualified effective-reactor context selection, local runtime mappings, shared compatibility gating, bounded wire codec and authentication/correlation state. See [implementation and validation](configuration-and-protocol.md). Effective Maven model acquisition, worker transport and setup UI retain their later implementation gates. +Implemented: packaged schemas, profile-qualified effective-reactor context selection, local runtime mappings, shared compatibility gating, bounded wire codec and authentication/correlation state. See [implementation and validation](configuration-and-protocol.md). Effective Maven capture and worker/minimal UI wiring are implemented in work packages 5 and 7; a general setup wizard remains later work. Implement JSON schema, context resolution and local runtime mappings. Turn the illustrative fields into a versioned v1 schema and fixtures. Check invalid paths/modules, missing runtimes, unknown schema versions/capabilities, multiple matching contexts and parent-to-Component context inheritance. Define protocol framing, bounded message/image payloads and negotiation/error fixtures before worker implementation depends on them. @@ -66,7 +66,7 @@ Implement JSON schema, context resolution and local runtime mappings. Turn the i ### 5. Implement Build and Refresh with immutable snapshots -Implemented: shared coordinator, scoped effective Maven capture, explicit draft save approval, isolated builds, consistent private runtime copies, atomic publication, persistent outdated/last-successful state and cross-process snapshot leases. See [implementation and limits](build-and-refresh.md). The UI and worker restart consume these interfaces in work package 7. +Implemented: shared coordinator, scoped effective Maven capture, explicit draft save approval, isolated builds, consistent private runtime copies, atomic publication, persistent outdated/last-successful state and cross-process snapshot leases. See [implementation and limits](build-and-refresh.md). The UI and worker restart now consume these interfaces in work package 7. Resolve scoped Maven builds, preview source arrangements, explicit save/cancel and runtime choices. Capture successful outputs/resources/dependencies with an input/output manifest. Validate consistency and publish atomically; pin active snapshots against eviction. Provide explicit last-successful-build selection and stale diagnostics. @@ -74,7 +74,7 @@ Resolve scoped Maven builds, preview source arrangements, explicit save/cancel a ### 6. Validate the preview API and target adapter against real fixtures -Implemented: resource-only discovery, typed sessions, real NeitzelLib loading/rendering and 24 classpath/module-path process checks. See [qualification and lifecycle limits](preview-target-fixtures.md). Library-held Model/ViewModel state requires a fresh JVM per session; external shutdown enforcement remains work package 7. +Implemented: resource-only discovery, typed sessions, real NeitzelLib loading/rendering and 24 classpath/module-path process checks. See [qualification and lifecycle limits](preview-target-fixtures.md). Library-held Model/ViewModel state requires a fresh JVM per session; external shutdown enforcement is now implemented in work package 7. Add classpath/module-path Maven fixture applications using NeitzelLib artifacts. Implement non-constructing descriptor discovery and typed provider/session lifecycle against the real library. Test fresh Models/services, readiness, cleanup, invalid attributes and write-back. Freeze public Java signatures only after these tests establish the adapter contract. @@ -82,6 +82,8 @@ Add classpath/module-path Maven fixture applications using NeitzelLib artifacts. ### 7. Complete the first workflow in both minimal hosts +Implemented: both minimal hosts use the same Kit UI and workflow, immutable snapshots, authenticated worker transport, parent/Component overlays, native document edits and undo/redo. The narrow Kit selection fix is carried with provenance. See [implementation, qualification and limits](first-connected-workflow.md). Broader projection, interactive preview controls and product qualification remain subsequent work. + Connect shared core/UI to the worker protocol and fixtures. Exercise Build and Refresh, discovery, parent/Component draft overlays and target-runtime render in standalone and IntelliJ. Use the qualified exclusive JavaFX 26 host setup; keep application runtime execution outside each host. Carry only the verified narrow Kit patch where still needed, with provenance and regression coverage. **Exit:** all initial-contract acceptance requirements pass in both hosts with logs/artifact identities and explicit limitations. Then proceed to roadmap stage 2 and later feature stages. Full handoff, binding/generation UI, broader projection/control support and product packaging are not silently included in this first milestone. diff --git a/integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java b/integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java index a1e3b99..b675426 100644 --- a/integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java +++ b/integration-fixtures/preview/classpath/app/src/test/java/fixture/preview/Examples.java @@ -24,6 +24,7 @@ public final class Examples implements PreviewProvider open(String id) throws Exception { if (!javafx.application.Platform.isFxApplicationThread()) throw new AssertionError("Provider not on FX"); + System.out.println("Application console: NFX1 is ordinary text, not a wire frame"); person = new Person(); context = new ApplicationContext("fixture.components"); service = context.getComponent(Greeting.class); diff --git a/integration-fixtures/preview/classpath/neitzel-fx-designer.json b/integration-fixtures/preview/classpath/neitzel-fx-designer.json index 7b5d72a..861e8f0 100644 --- a/integration-fixtures/preview/classpath/neitzel-fx-designer.json +++ b/integration-fixtures/preview/classpath/neitzel-fx-designer.json @@ -8,7 +8,8 @@ "targetRuntime": "java21", "preview": { "sourceSet": "test", - "provider": "fixture.preview.Examples" + "provider": "fixture.preview.Examples", + "defaultExample": "standard" } } } diff --git a/integration-fixtures/preview/modulepath/neitzel-fx-designer.json b/integration-fixtures/preview/modulepath/neitzel-fx-designer.json index 74bc31c..0ab1a7b 100644 --- a/integration-fixtures/preview/modulepath/neitzel-fx-designer.json +++ b/integration-fixtures/preview/modulepath/neitzel-fx-designer.json @@ -9,10 +9,11 @@ "preview": { "sourceSet": "module", "module": "preview", - "provider": "fixture.preview.Examples" + "provider": "fixture.preview.Examples", + "defaultExample": "standard" }, "modulePath": { - "entryModule": "fixture.checks" + "entryModule": "fixture.preview" } } } diff --git a/integration-fixtures/preview/modulepath/preview/src/main/java/fixture/preview/Examples.java b/integration-fixtures/preview/modulepath/preview/src/main/java/fixture/preview/Examples.java index a1e3b99..b675426 100644 --- a/integration-fixtures/preview/modulepath/preview/src/main/java/fixture/preview/Examples.java +++ b/integration-fixtures/preview/modulepath/preview/src/main/java/fixture/preview/Examples.java @@ -24,6 +24,7 @@ public final class Examples implements PreviewProvider open(String id) throws Exception { if (!javafx.application.Platform.isFxApplicationThread()) throw new AssertionError("Provider not on FX"); + System.out.println("Application console: NFX1 is ordinary text, not a wire frame"); person = new Person(); context = new ApplicationContext("fixture.components"); service = context.getComponent(Greeting.class); diff --git a/integration-tests/idea/WorkflowProbe.java b/integration-tests/idea/WorkflowProbe.java new file mode 100644 index 0000000..25feb91 --- /dev/null +++ b/integration-tests/idea/WorkflowProbe.java @@ -0,0 +1,277 @@ +package qualification; + +import com.intellij.openapi.application.*; +import com.intellij.openapi.fileEditor.*; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.StartupActivity; +import com.intellij.openapi.vfs.*; +import com.oracle.javafx.scenebuilder.kit.fxom.*; +import de.neitzel.fxdesigner.kit.*; +import java.nio.file.*; +import java.util.concurrent.*; +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; +import javafx.scene.control.Button; + +/** Disposable companion plugin, never included in the delivered designer plugin. */ +public final class WorkflowProbe implements StartupActivity.DumbAware { + static final Path output = Path.of(System.getProperty("designer.check.output")); + static DesignerPane active; + static final java.util.concurrent.atomic.AtomicBoolean started = + new java.util.concurrent.atomic.AtomicBoolean(); + + static T edt(Callable action) throws Exception { + var f = new CompletableFuture(); + ApplicationManager.getApplication() + .invokeLater( + () -> + WriteIntentReadAction.run( + () -> { + try { + f.complete(action.call()); + } catch (Throwable t) { + f.completeExceptionally(t); + } + }), + ModalityState.nonModal()); + return f.get(20, TimeUnit.SECONDS); + } + + static T fx(Callable action) throws Exception { + var f = new CompletableFuture(); + Platform.runLater( + () -> { + try { + f.complete(action.call()); + } catch (Throwable t) { + f.completeExceptionally(t); + } + }); + return f.get(20, TimeUnit.SECONDS); + } + + static JFXPanel bridge(java.awt.Component c) { + if (c instanceof JFXPanel p) return p; + if (c instanceof java.awt.Container parent) + for (var child : parent.getComponents()) { + var p = bridge(child); + if (p != null) return p; + } + return null; + } + + static void await(Callable condition) throws Exception { + long until = System.nanoTime() + TimeUnit.SECONDS.toNanos(120); + while (System.nanoTime() < until) { + if (condition.call()) return; + Thread.sleep(100); + } + throw new AssertionError("IDE workflow timed out"); + } + + static void click(DesignerPane pane, String text) throws Exception { + fx( + () -> { + ((Button) + pane.lookupAll(".button").stream() + .filter(n -> n instanceof Button b && b.getText().equals(text)) + .findFirst() + .orElseThrow()) + .fire(); + return null; + }); + } + + public void runActivity(Project project) { + if (!started.compareAndSet(false, true)) return; + ApplicationManager.getApplication() + .executeOnPooledThread( + () -> { + try { + Path path = + Path.of( + project.getBasePath(), + "app/src/main/resources/fixture/components/Person.fxml"); + VirtualFile file = + edt(() -> LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path)); + FileEditor editor = + edt( + () -> { + var manager = FileEditorManager.getInstance(project); + var all = manager.openFile(file, true); + manager.setSelectedEditor(file, "neitzel-fx-designer"); + return java.util.Arrays.stream(all) + .filter(e -> e.getName().equals("Neitzel Designer")) + .findFirst() + .orElseThrow(); + }); + await(() -> edt(() -> bridge(editor.getComponent()) != null)); + JFXPanel bridge = edt(() -> bridge(editor.getComponent())); + await( + () -> + fx( + () -> + bridge.getScene() != null + && bridge.getScene().getRoot() instanceof DesignerPane)); + DesignerPane pane = fx(() -> (DesignerPane) bridge.getScene().getRoot()); + active = pane; + await(() -> fx(() -> pane.kit().editor().getFxomDocument() != null)); + click(pane, "Build and Refresh"); + await( + () -> + fx( + () -> + java.util.List.of("card", "second").stream() + .allMatch( + id -> { + var item = + new FXOMFxIdIndex( + pane.kit().editor().getFxomDocument()) + .lookup(id); + return item instanceof FXOMInstance instance + && instance.getSceneGraphObject() + instanceof SnapshotRegion region + && region.image() != null + && region.statusText().equals("Target runtime"); + }))); + fx( + () -> { + var kit = pane.kit().editor(); + var card = + (FXOMInstance) new FXOMFxIdIndex(kit.getFxomDocument()).lookup("card"); + kit.getSelection().select(card); + var property = + (FXOMPropertyT) + card.getProperties() + .get( + new com.oracle.javafx.scenebuilder.kit.metadata.util + .PropertyName("caption")); + String old = property.getValue(); + kit.getJobManager() + .push( + new com.oracle.javafx.scenebuilder.kit.editor.job.Job(kit) { + public boolean isExecutable() { + return true; + } + + public String getDescription() { + return "IDE caption check"; + } + + public void execute() { + set("IDE canvas draft"); + } + + public void undo() { + set(old); + } + + public void redo() { + execute(); + } + + void set(String text) { + var doc = kit.getFxomDocument(); + doc.beginUpdate(); + try { + property.setValue(text); + } finally { + doc.endUpdate(); + } + } + }); + return null; + }); + await( + () -> + edt( + () -> + FileDocumentManager.getInstance() + .getDocument(file) + .getText() + .contains("IDE canvas draft"))); + click(pane, "Undo"); + await( + () -> + edt( + () -> + FileDocumentManager.getInstance() + .getDocument(file) + .getText() + .contains("FXML caption"))); + click(pane, "Redo"); + await( + () -> + edt( + () -> + FileDocumentManager.getInstance() + .getDocument(file) + .getText() + .contains("IDE canvas draft"))); + click(pane, "Render drafts"); + await( + () -> + fx( + () -> + java.util.List.of("card", "second").stream() + .allMatch( + id -> { + var item = + new FXOMFxIdIndex( + pane.kit().editor().getFxomDocument()) + .lookup(id); + return item instanceof FXOMInstance instance + && instance.getSceneGraphObject() + instanceof SnapshotRegion region + && region.image() != null + && region.statusText().equals("Target runtime"); + }))); + fx( + () -> { + javax.imageio.ImageIO.write( + javafx.embed.swing.SwingFXUtils.fromFXImage( + pane.snapshot(null, null), null), + "png", + output.resolve("intellij.png").toFile()); + return null; + }); + String version = fx(() -> System.getProperty("javafx.runtime.version")); + if (!version.startsWith("26")) + throw new AssertionError("Wrong host JavaFX: " + version); + Files.writeString( + output.resolve("SUCCESS.txt"), + "PASS registered IntelliJ editor, shared Kit, real Build and Refresh, target" + + " pixels, native document edits, undo/redo, draft render; JavaFX " + + version + + "\n"); + } catch (Throwable e) { + try { + if (active != null) + fx( + () -> { + Files.writeString( + output.resolve("ui-state.txt"), + active.lookupAll(".label").stream() + .filter(n -> n instanceof javafx.scene.control.Label) + .map( + n -> + String.valueOf( + ((javafx.scene.control.Label) n).getText())) + .toList() + .toString()); + javax.imageio.ImageIO.write( + javafx.embed.swing.SwingFXUtils.fromFXImage( + active.snapshot(null, null), null), + "png", + output.resolve("failed.png").toFile()); + return null; + }); + var w = new java.io.StringWriter(); + e.printStackTrace(new java.io.PrintWriter(w)); + Files.writeString(output.resolve("FAILURE.txt"), w.toString()); + } catch (Exception ignored) { + } + } + }); + } +} diff --git a/scripts/check-bootstrap.py b/scripts/check-bootstrap.py index 22bbb11..83afa99 100644 --- a/scripts/check-bootstrap.py +++ b/scripts/check-bootstrap.py @@ -42,6 +42,7 @@ run([wrapper, "-B", "-ntp", "-pl", "designer-standalone", "org.apache.maven.plug smoke_classpath = os.pathsep.join([str(ROOT / "designer-standalone/target/test-classes"), str(ROOT / "designer-standalone/target/classes"), (ROOT / "designer-standalone/target/smoke-classpath.txt").read_text().strip()]) run([Path(os.environ["JAVA_HOME"]) / "bin/java", "-cp", smoke_classpath, "de.neitzel.fxdesigner.build.BuildRefreshSmoke", ROOT, args.target_java_home]) run([ROOT / "designer-intellij" / ("gradlew.bat" if os.name == "nt" else "gradlew"), "-p", "designer-intellij", "--no-daemon", "-PideaHome=" + str(idea), "clean", "test", "buildPlugin", "verifyPluginProjectConfiguration"]) +run([sys.executable, ROOT / "scripts/check-connected-workflow.py", "--idea-home", args.idea_home, "--target-java-home", args.target_java_home]) with zipfile.ZipFile(next((ROOT / "designer-intellij/build/distributions").glob("*.zip"))) as plugin: names = plugin.namelist() assert any("javafx-graphics-26-mac" in n for n in names), "Missing host JavaFX implementation" diff --git a/scripts/check-connected-workflow.py b/scripts/check-connected-workflow.py new file mode 100644 index 0000000..33d9362 --- /dev/null +++ b/scripts/check-connected-workflow.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Real snapshot/socket/overlay checks against both host document adapters.""" +from pathlib import Path +import argparse +import os +import shutil +import subprocess +import tempfile +import json +import time + +ROOT = Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--target-java-home", required=True) +parser.add_argument("--idea-home", required=True) +args = parser.parse_args() +logs = ROOT / "target/connected-workflow" +logs.mkdir(parents=True, exist_ok=True) +classpath = os.pathsep.join([str(ROOT / "designer-standalone/target/test-classes"), + str(ROOT / "designer-standalone/target/classes"), + (ROOT / "designer-standalone/target/smoke-classpath.txt").read_text().strip()]) + + +def run(name, command): + with (logs / (name + ".log")).open("w") as output: + result = subprocess.run(list(map(str, command)), cwd=ROOT, stdout=output, + stderr=subprocess.STDOUT, timeout=420) + if result.returncode: + raise RuntimeError((logs / (name + ".log")).read_text()[-16000:]) + print("PASS " + name, flush=True) + + +with tempfile.TemporaryDirectory(prefix="designer-connected-") as temporary: + for host in ("standalone", "intellij"): + base = Path(temporary) / host + for mode in ("classpath", "modulepath"): + fixture = base / mode + shutil.copytree(ROOT / "integration-fixtures/preview" / mode, fixture, + ignore=shutil.ignore_patterns("target")) + if host == "standalone": + run(host + "-" + mode, [Path(os.environ["JAVA_HOME"]) / "bin/java", "-cp", classpath, + "de.neitzel.fxdesigner.standalone.WorkflowSmoke", ROOT, args.target_java_home, + fixture, mode]) + local = fixture / ".neitzel-fx-designer" + (local / "runtimes.json").write_text(json.dumps({"schemaVersion":"1.0","runtimes":{"java21":{"build":args.target_java_home,"target":args.target_java_home}}})) + tools = [ROOT / ("designer-" + name) / "target" / ("designer-" + name + "-0.1.0-SNAPSHOT.jar") for name in ("target-tools","contracts","preview-api")] + (local / "host.properties").write_text("configuration=../neitzel-fx-designer.json\ncontext=fixture\nsettings=runtimes.json\nmaven="+str(ROOT/"mvnw")+"\ntools="+os.pathsep.join(map(str,tools))+"\n") + run("native-kit-" + mode, [Path(os.environ["JAVA_HOME"]) / "bin/java", "--enable-native-access=ALL-UNNAMED", "-cp", classpath, + "de.neitzel.fxdesigner.standalone.KitWorkflowSmoke", local / "host.properties", + fixture / "app/src/main/resources/fixture/components/Person.fxml", logs / ("kit-" + mode + ".png")]) + if mode == "classpath": + pidfile = base / "worker.pid" + owner = subprocess.Popen([str(Path(os.environ["JAVA_HOME"]) / "bin/java"), + "-cp", classpath, "de.neitzel.fxdesigner.standalone.WorkerOwnerProbe", + str(ROOT), str(fixture), str(pidfile)], cwd=ROOT) + try: + until = time.monotonic() + 20 + while not pidfile.exists() and time.monotonic() < until: + if owner.poll() is not None: + raise RuntimeError("Owner probe exited before startup") + time.sleep(.05) + worker_pid = int(pidfile.read_text()) + owner.kill() + owner.wait(timeout=5) + until = time.monotonic() + 10 + while time.monotonic() < until: + try: + os.kill(worker_pid, 0) + except ProcessLookupError: + break + time.sleep(.05) + else: + raise RuntimeError("Worker survived abrupt owner death") + print("PASS abrupt owner death", flush=True) + finally: + if owner.poll() is None: + owner.kill() + owner.wait(timeout=5) + + if host == "intellij": + run(host, [ROOT / "designer-intellij/gradlew", "-p", "designer-intellij", "--no-daemon", + "-PideaHome=" + args.idea_home, "-Ddesigner.workflow.fixtures=" + str(base), + "-Ddesigner.repository=" + str(ROOT), "-Ddesigner.target.java=" + args.target_java_home, + "test"]) diff --git a/scripts/check-ide-host.py b/scripts/check-ide-host.py new file mode 100644 index 0000000..bf62420 --- /dev/null +++ b/scripts/check-ide-host.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Run the disposable companion checks in real isolated IntelliJ windows (macOS).""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess +import sys +import time +import zipfile + +ROOT = Path(__file__).resolve().parent.parent +p = argparse.ArgumentParser(description=__doc__) +p.add_argument("--java-home", type=Path, required=True) +p.add_argument("--target-java-home", type=Path, required=True) +p.add_argument("--idea-home", type=Path, required=True) +args = p.parse_args() +output = ROOT / "target/ide-workflow" +output.mkdir(parents=True, exist_ok=True) +for mode in ("classpath", "modulepath"): + launch = subprocess.run([sys.executable, ROOT / "scripts/run-fixture-host.py", "intellij", + "--check", "--mode", mode, "--java-home", args.java_home, + "--target-java-home", args.target_java_home, "--idea-home", args.idea_home], + cwd=ROOT, text=True, capture_output=True, check=True, timeout=60) + (output / (mode + "-launch.log")).write_text(launch.stdout + launch.stderr) + base = Path(re.search(r"Disposable host: (.+)", launch.stdout)[1]) + pid = int(re.search(r"Host PID: (\d+)", launch.stdout)[1]) + try: + until = time.monotonic() + 240 + while time.monotonic() < until: + if (base / "FAILURE.txt").exists(): + raise RuntimeError((base / "FAILURE.txt").read_text()) + if (base / "SUCCESS.txt").exists(): + break + time.sleep(.25) + else: + raise RuntimeError("IDE qualification timed out: " + str(base)) + natives = subprocess.run([args.java_home / "bin/jcmd", str(pid), "VM.dynlibs"], + capture_output=True, text=True, check=True, timeout=20).stdout + (output / (mode + "-natives.txt")).write_text(natives) + paths = sorted(set(re.findall(r"(/[^\r\n]*?libglass\.dylib)", natives))) + expected = {} + for artifact in (Path.home() / ".m2/repository/org/openjfx").glob("*/26/*-mac*.jar"): + with zipfile.ZipFile(artifact) as jar: + for name in jar.namelist(): + if name.endswith(".dylib"): + expected.setdefault(Path(name).name, set()).add(hashlib.sha256(jar.read(name)).hexdigest()) + actual = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in paths} + assert len(actual) == 1 and next(iter(actual.values())) in expected["libglass.dylib"], actual + all_paths = sorted(set(re.findall(r"(/[^\r\n]*?/\.openjfx/[^\r\n]*?\.dylib)", natives))) + all_native = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in all_paths} + assert all(digest in expected.get(Path(path).name, set()) for path, digest in all_native.items()) + proof = {"mode": mode, "host": str(base), "glass": actual, "openjfx": all_native, + "result": (base / "SUCCESS.txt").read_text()} + (output / (mode + ".json")).write_text(json.dumps(proof, indent=2) + "\n") + shutil.copyfile(base / "intellij.png", output / (mode + ".png")) + print("PASS registered IntelliJ " + mode + " editor and exclusive JavaFX 26 natives", flush=True) + finally: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass diff --git a/scripts/prepare-fixture-host.py b/scripts/prepare-fixture-host.py new file mode 100644 index 0000000..70c1517 --- /dev/null +++ b/scripts/prepare-fixture-host.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Create a fresh editable development fixture and explicit local host setup.""" +import argparse +import json +from pathlib import Path +import shutil +ROOT = Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--destination", type=Path, required=True) +parser.add_argument("--target-java-home", type=Path, required=True) +parser.add_argument("--mode", choices=["classpath", "modulepath"], default="classpath") +args = parser.parse_args() +destination = args.destination.resolve() +shutil.copytree(ROOT / "integration-fixtures/preview" / args.mode, destination, + ignore=shutil.ignore_patterns("target")) +local = destination / ".neitzel-fx-designer" +local.mkdir() +(local / "runtimes.json").write_text(json.dumps({"schemaVersion":"1.0","runtimes":{"java21":{"build":str(args.target_java_home.resolve()),"target":str(args.target_java_home.resolve())}}})) +tools = [ROOT / ("designer-" + name) / "target" / ("designer-" + name + "-0.1.0-SNAPSHOT.jar") + for name in ("target-tools","contracts","preview-api")] +# Properties separators in values do not need escaping; double backslashes do. +def prop(value): return str(value).replace("\\", "\\\\") +(local / "host.properties").write_text( + "configuration=../neitzel-fx-designer.json\ncontext=fixture\nsettings=runtimes.json\n" + + "maven=" + prop(ROOT / "mvnw") + "\ntools=" + ":".join(map(prop, tools)) + "\n") +print(local / "host.properties") +print(destination / "app/src/main/resources/fixture/components/Person.fxml") diff --git a/scripts/run-fixture-host.py b/scripts/run-fixture-host.py new file mode 100644 index 0000000..b5ee79d --- /dev/null +++ b/scripts/run-fixture-host.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Launch a disposable standalone or isolated IntelliJ fixture host (macOS development).""" +import argparse +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import zipfile +from xml.sax.saxutils import escape + +ROOT = Path(__file__).resolve().parent.parent +p = argparse.ArgumentParser(description=__doc__) +p.add_argument("host", choices=["standalone","intellij"]) +p.add_argument("--java-home", type=Path, required=True) +p.add_argument("--target-java-home", type=Path, required=True) +p.add_argument("--idea-home", type=Path) +p.add_argument("--check", action="store_true", help="Install the disposable IDE qualification companion") +p.add_argument("--mode", choices=["classpath","modulepath"], default="classpath") +args = p.parse_args() +base = Path(tempfile.mkdtemp(prefix="neitzel-designer-host-")).resolve() +project = base / "project" +subprocess.run([sys.executable, ROOT / "scripts/prepare-fixture-host.py", "--destination", project, + "--target-java-home", args.target_java_home, "--mode", args.mode], check=True) +settings = project / ".neitzel-fx-designer/host.properties" +file = project / "app/src/main/resources/fixture/components/Person.fxml" +env = dict(os.environ) +if args.host == "standalone": + cp = os.pathsep.join([str(ROOT / "designer-standalone/target/classes"), + (ROOT / "designer-standalone/target/smoke-classpath.txt").read_text().strip()]) + command = [str(args.java_home / "bin/java"), "--enable-native-access=ALL-UNNAMED", "-cp", cp, + "de.neitzel.fxdesigner.standalone.Launcher", str(settings), str(file)] +else: + if args.idea_home is None: + p.error("--idea-home is required for IntelliJ") + plugin = next((ROOT / "designer-intellij/build/distributions").glob("*.zip")) + with zipfile.ZipFile(plugin) as archive: + archive.extractall(base / "plugins") + if args.check: + classes = base / "check-classes" + classes.mkdir() + jars = [*(args.idea_home / "lib").glob("*.jar"), *(base / "plugins").rglob("*.jar")] + subprocess.run([str(args.java_home / "bin/javac"), "-cp", os.pathsep.join(map(str,jars)), "-d", str(classes), + str(ROOT / "integration-tests/idea/WorkflowProbe.java")], check=True) + meta = classes / "META-INF" + meta.mkdir() + (meta / "plugin.xml").write_text('de.neitzel.fxdesigner.qualificationDesigner workflow qualification1Local testscom.intellij.modules.platformde.neitzel.fxdesigner') + jar = base / "plugins/qualification/lib/qualification.jar" + jar.parent.mkdir(parents=True) + subprocess.run([str(args.java_home / "bin/jar"), "cf", str(jar), "-C", str(classes), "."], check=True) + options = base / "config/options" + options.mkdir(parents=True) + (options / "ide.general.xml").write_text('') + (options / "trusted-paths.xml").write_text('') + props = base / "idea.properties" + props.write_text("\n".join("idea."+name+".path="+str(base/name) for name in ["config","system","plugins","log"])+"\nidea.initially.ask.config=false\n") + vm = base / "idea.vmoptions" + vm.write_text((args.idea_home / "bin/idea.vmoptions").read_text()+"\n--enable-native-access=ALL-UNNAMED\n-Dneitzel.designer.launch="+str(settings)+"\n-Ddesigner.check.output="+str(base)+"\n") + env.update(IDEA_PROPERTIES=str(props), IDEA_VM_OPTIONS=str(vm), IDEA_JDK=str(args.java_home)) + command = [str(args.idea_home / "MacOS/idea"), str(project), str(file)] +with (base / "launcher.log").open("w") as log: + process = subprocess.Popen(command, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) +(base / "host.pid").write_text(str(process.pid)) +print("Host PID:", process.pid) +print("Disposable host:", base) +print("FXML:", file)