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