Connect minimal hosts to snapshots, target workers and shared Kit editing

This commit is contained in:
2026-11-12 11:07:45 +01:00
parent 28c1e47c95
commit 7db7218a6e
52 changed files with 4236 additions and 29 deletions
@@ -0,0 +1,582 @@
package de.neitzel.fxdesigner.kit;
import com.oracle.javafx.scenebuilder.kit.editor.panel.content.ContentPanelController;
import com.oracle.javafx.scenebuilder.kit.editor.panel.hierarchy.HierarchyPanelController;
import com.oracle.javafx.scenebuilder.kit.editor.panel.inspector.InspectorPanelController;
import com.oracle.javafx.scenebuilder.kit.fxom.*;
import de.neitzel.fxdesigner.build.BuildRefresh;
import de.neitzel.fxdesigner.contracts.*;
import de.neitzel.fxdesigner.host.*;
import de.neitzel.fxdesigner.workflow.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import javafx.animation.*;
import javafx.application.Platform;
import javafx.scene.control.*;
import javafx.scene.image.*;
import javafx.scene.layout.*;
import javafx.util.Duration;
/** One shared Kit UI. Both hosts own the authoritative DocumentSession and native history. */
public final class DesignerPane extends BorderPane implements AutoCloseable {
private final HostServices host;
private final HostLaunch launch;
private final Path file;
private final KitView kit;
private final Label status = new Label("Opening document");
private final TextArea source = new TextArea(), output = new TextArea();
private final ComboBox<DesignerWorkflow.Example> examples = new ComboBox<>();
private final SplitPane design;
private final Timeline poll;
private final PauseTransition typing = new PauseTransition(Duration.millis(250)),
resize = new PauseTransition(Duration.millis(350));
private String submittedSource;
private DocumentSession document;
private DocumentSnapshot draft;
private FxmlProjection projection;
private DesignerWorkflow workflow;
private boolean loading, closed, pending, last;
private final java.util.concurrent.atomic.AtomicLong renderSequence =
new java.util.concurrent.atomic.AtomicLong();
private AutoCloseable changes;
private final Map<String, Image> images = new HashMap<>();
private BuildRefresh.Run buildRun;
public DesignerPane(HostServices host, HostLaunch launch, Path file) throws Exception {
this.host = host;
this.launch = launch;
this.file = file;
Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
kit = new KitView(host);
design =
new SplitPane(
new HierarchyPanelController(kit.editor()).getPanelRoot(),
new ContentPanelController(kit.editor()).getPanelRoot(),
new InspectorPanelController(kit.editor()).getPanelRoot());
design.setDividerPositions(.18, .72);
workflow =
new DesignerWorkflow(
host,
launch.configuration(),
launch.context(),
launch.maven(),
launch.tools(),
this::log);
Path reactor = launch.configuration().getParent().toRealPath();
changes =
host.documents()
.onChange(
uri -> {
if (Path.of(uri).startsWith(reactor)) {
renderSequence.incrementAndGet();
workflow.cancel();
}
});
var bar =
new ToolBar(
button("Build and Refresh", () -> build(Map.of())),
button(
"Cancel",
() -> {
if (buildRun != null) buildRun.cancel();
workflow.cancel();
renderSequence.incrementAndGet();
}),
examples,
button("Render drafts", this::render),
button("Preview last successful", () -> discover(true)),
button(
"Save",
() -> withDraft(() -> document.save(draft.revision()).whenComplete(this::updated))),
button("Undo", () -> history(false)),
button("Redo", () -> history(true)),
button("Open Component", this::openComponent));
examples.setConverter(
new javafx.util.StringConverter<>() {
public String toString(DesignerWorkflow.Example value) {
return value == null ? "Select example" : value.label();
}
public DesignerWorkflow.Example fromString(String value) {
return null;
}
});
var tabs = new TabPane();
var split = new SplitPane(source, design);
split.setDividerPositions(.3);
tabs.getTabs().add(new Tab("Source / Design", split));
tabs.getTabs().getFirst().setClosable(false);
output.setEditable(false);
output.setPrefRowCount(5);
setTop(bar);
setCenter(tabs);
setBottom(new VBox(status, output));
source
.textProperty()
.addListener(
(o, a, b) -> {
if (!loading) {
commitSource();
workflow.cancel();
renderSequence.incrementAndGet();
}
});
typing.setOnFinished(e -> commitSource());
resize.setOnFinished(e -> render());
poll =
new Timeline(
new KeyFrame(
Duration.millis(400),
e -> {
if (document != null
&& !pending
&& !typing.getStatus().equals(Animation.Status.RUNNING))
document
.snapshot()
.whenComplete(
(d, error) ->
fx(
() -> {
if (error != null) report(error);
else if (draft == null || d.revision() != draft.revision())
load(d);
}));
}));
poll.setCycleCount(Animation.INDEFINITE);
host.documents()
.open(file.toUri())
.whenComplete(
(d, error) ->
fx(
() -> {
if (error != null) {
report(error);
return;
}
if (closed) {
d.close();
return;
}
document = d;
d.snapshot().whenComplete(this::updated);
poll.play();
}));
}
private Button button(String text, Runnable action) {
var b = new Button(text);
b.setOnAction(
e -> {
try {
action.run();
} catch (Throwable error) {
report(error);
}
});
return b;
}
private void fx(Runnable action) {
Platform.runLater(
() -> {
if (!closed) action.run();
});
}
private void log(String text) {
fx(
() -> {
output.appendText(text + "\n");
if (output.getLength() > 60000) output.deleteText(0, output.getLength() - 50000);
});
}
private void report(Throwable error) {
host.diagnostics()
.report(
new HostServices.Diagnostic(
"Designer", HostServices.Severity.ERROR, String.valueOf(error), Optional.empty()));
status.setText("Designer: " + error.getMessage());
log(error.toString());
}
private void updated(DocumentSnapshot value, Throwable error) {
fx(
() -> {
pending = false;
if (error != null) {
submittedSource = null;
report(error);
return;
}
if (submittedSource != null && !source.getText().equals(submittedSource)) {
draft = value;
submittedSource = null;
commitSource();
return;
}
submittedSource = null;
load(value);
});
}
private void load(DocumentSnapshot value) {
if (closed) return;
loading = true;
renderSequence.incrementAndGet();
workflow.cancel();
try {
draft = value;
if (!source.getText().equals(value.text())) source.setText(value.text());
source.setEditable(value.writable());
// End an inspector editing session before replacing FXOM (also after native undo).
if (getScene() != null
&& getScene().getFocusOwner() instanceof TextInputControl text
&& text != source) text.getParent().requestFocus();
projection = new FxmlProjection(value.text());
String projected = projection.project();
kit.editor().setFxmlTextAndLocation(projected, file.toUri().toURL());
design.setDisable(!value.writable());
kit.editor()
.getFxomDocument()
.sceneGraphRevisionProperty()
.addListener((o, a, b) -> canvasChanged());
for (String id : projection.components().keySet()) {
var region = proxy(id);
if (region != null) {
region.show(
images.get(id), images.containsKey(id) ? "Previous preview" : "Render preview");
region
.widthProperty()
.addListener(
(o, a, b) -> {
if (!loading) resize.playFromStart();
});
region
.heightProperty()
.addListener(
(o, a, b) -> {
if (!loading) resize.playFromStart();
});
}
}
status.setText(
"Context "
+ launch.context()
+ " · document revision "
+ value.revision()
+ (value.dirty() ? " · unsaved" : ""));
} catch (Throwable error) {
design.setDisable(true);
report(error);
} finally {
loading = false;
}
}
private SnapshotRegion proxy(String id) {
var object = new FXOMFxIdIndex(kit.editor().getFxomDocument()).lookup(id);
return object instanceof FXOMInstance instance
&& instance.getSceneGraphObject() instanceof SnapshotRegion region
? region
: null;
}
private void replace(String text, String name) {
if (draft == null || pending) return;
pending = true;
document
.apply(
new EditBatch(
draft.revision(),
name,
List.of(new EditBatch.Replacement(0, draft.text().length(), text))))
.whenComplete(this::updated);
}
private void commitSource() {
if (draft != null && !pending && !source.getText().equals(draft.text())) {
submittedSource = source.getText();
replace(submittedSource, "Edit FXML source");
}
}
private void canvasChanged() {
if (loading || pending || closed) return;
try {
replace(
projection.restore(kit.editor().getFxomDocument().getFxmlText(false)),
"Component layout / property edit");
} catch (Throwable error) {
report(error);
load(draft);
}
}
private void withDraft(Runnable action) {
if (document == null) return;
if (pending || !source.getText().equals(draft.text())) {
commitSource();
status.setText("Finishing source edit; repeat the action when synchronized");
return;
}
action.run();
}
private void history(boolean redo) {
withDraft(() -> (redo ? document.redo() : document.undo()).whenComplete(this::updated));
}
private void build(Map<java.net.URI, Long> approvals) {
withDraft(
() -> {
renderSequence.incrementAndGet();
status.setText("Building selected context…");
buildRun = workflow.build(approvals);
buildRun
.completion()
.whenComplete(
(result, error) ->
fx(
() -> {
if (error != null) {
report(error);
return;
}
status.setText(result.message());
if (result.status() == BuildRefresh.Status.NEEDS_SAVE) {
var alert =
new Alert(
Alert.AlertType.CONFIRMATION,
"Save these build inputs?\n"
+ String.join(
"\n",
result.unsaved().stream()
.map(d -> d.document().toString())
.toList()),
ButtonType.OK,
ButtonType.CANCEL);
alert.setHeaderText("Save affected files and build");
if (alert.showAndWait().orElse(ButtonType.CANCEL) == ButtonType.OK) {
var approved = new HashMap<java.net.URI, Long>();
result
.unsaved()
.forEach(d -> approved.put(d.document(), d.revision()));
build(approved);
}
} else if (result.status() == BuildRefresh.Status.READY)
discover(false);
}));
});
}
public void discover(boolean useLast) {
withDraft(
() -> {
last = useLast;
status.setText(
useLast ? "Discovering explicitly retained build…" : "Discovering successful build…");
workflow
.discover(useLast)
.whenComplete(
(entries, error) ->
fx(
() -> {
if (error != null) {
report(error);
return;
}
examples.getItems().setAll(entries);
if (!entries.isEmpty()) {
String preferred =
de.neitzel.fxdesigner.config.ConfigurationFiles.load(
launch.configuration())
.contexts()
.get(launch.context())
.preview()
.defaultExample();
examples
.getSelectionModel()
.select(
entries.stream()
.filter(e -> e.id().equals(preferred))
.findFirst()
.orElse(entries.getFirst()));
}
status.setText(
(last ? "Outdated build explicitly selected · " : "")
+ entries.size()
+ " examples");
render();
}));
});
}
public void render() {
withDraft(
() -> {
var selected = examples.getValue();
if (selected == null || projection == null) return;
long ticket = renderSequence.incrementAndGet();
host.documents()
.snapshots()
.whenComplete(
(dirty, error) ->
fx(
() -> {
if (error != null) {
report(error);
return;
}
if (ticket != renderSequence.get()) return;
var drafts = new LinkedHashMap<java.net.URI, DocumentSnapshot>();
drafts.put(draft.document(), draft);
dirty.stream()
.filter(
d ->
d.document().toString().endsWith(".fxml")
&& Path.of(d.document())
.startsWith(launch.configuration().getParent()))
.forEach(d -> drafts.put(d.document(), d));
renderComponents(List.copyOf(drafts.values()), selected.id(), ticket);
}));
});
}
private void renderComponents(List<DocumentSnapshot> drafts, String example, long ticket) {
if (ticket != renderSequence.get() || closed) return;
var nodes = new LinkedHashMap<String, DesignerWorkflow.Size>();
for (String id : projection.components().keySet()) {
var region = proxy(id);
if (region == null) continue;
int
width =
(int)
Math.max(
1,
Math.min(
1600, region.getWidth() > 0 ? region.getWidth() : region.prefWidth(-1))),
height =
(int)
Math.max(
1,
Math.min(
1200,
region.getHeight() > 0 ? region.getHeight() : region.prefHeight(-1)));
nodes.put(id, new DesignerWorkflow.Size(width, height));
region.updating();
}
if (nodes.isEmpty()) return;
workflow
.renderImages(last, example, nodes, drafts)
.whenComplete(
(frames, error) ->
fx(
() -> {
if (ticket != renderSequence.get()) return;
if (error != null) {
report(error);
return;
}
for (var frame : frames) {
int width = frame.width(), height = frame.height();
var image = new WritableImage(width, height);
byte[] rgba = frame.rgba();
int[] argb = new int[width * height];
for (int i = 0; i < argb.length; i++)
argb[i] =
((rgba[i * 4 + 3] & 255) << 24)
| ((rgba[i * 4] & 255) << 16)
| ((rgba[i * 4 + 1] & 255) << 8)
| (rgba[i * 4 + 2] & 255);
image
.getPixelWriter()
.setPixels(
0,
0,
width,
height,
PixelFormat.getIntArgbInstance(),
argb,
0,
width);
images.put(frame.node(), image);
var region = proxy(frame.node());
if (region != null)
region.show(image, last ? "Explicit retained build" : "Target runtime");
}
status.setText(
"Rendered snapshot "
+ frames.getFirst().snapshot()
+ " · revision "
+ draft.revision());
}));
}
private void openComponent() {
if (projection == null || projection.components().isEmpty()) return;
var choice =
new ChoiceDialog<>(
projection.components().keySet().iterator().next(), projection.components().keySet());
choice.setHeaderText("Open Component in a separate editor");
choice
.showAndWait()
.ifPresent(
id -> {
String type = projection.components().get(id);
workflow
.componentSource(type)
.thenCompose(child -> host.navigation().open(new HostServices.Location(child, 0)))
.exceptionally(
error -> {
fx(() -> report(error));
return null;
});
});
}
public CompletionStage<Void> flush() {
var result = new CompletableFuture<Void>();
flushInto(result, System.nanoTime() + TimeUnit.SECONDS.toNanos(5));
return result;
}
private void flushInto(CompletableFuture<Void> result, long deadline) {
if (System.nanoTime() > deadline) {
result.completeExceptionally(
new IllegalStateException("Source edit could not be synchronized"));
return;
}
if (closed) {
result.completeExceptionally(new IllegalStateException("Editor closed"));
return;
}
if (!pending && draft != null && source.getText().equals(draft.text())) {
result.complete(null);
return;
}
commitSource();
var wait = new PauseTransition(Duration.millis(50));
wait.setOnFinished(e -> flushInto(result, deadline));
wait.play();
}
public KitView kit() {
return kit;
}
public void close() {
if (closed) return;
closed = true;
try {
changes.close();
} catch (Exception ignored) {
}
poll.stop();
typing.stop();
resize.stop();
workflow.close();
if (document != null) document.close();
}
}