package de.neitzel.fxdesigner.kit; import com.oracle.javafx.scenebuilder.kit.editor.panel.content.ContentPanelController; import com.oracle.javafx.scenebuilder.kit.editor.panel.hierarchy.HierarchyPanelController; import com.oracle.javafx.scenebuilder.kit.editor.panel.inspector.InspectorPanelController; import com.oracle.javafx.scenebuilder.kit.fxom.*; import de.neitzel.fxdesigner.build.BuildRefresh; import de.neitzel.fxdesigner.contracts.*; import de.neitzel.fxdesigner.host.*; import de.neitzel.fxdesigner.workflow.*; import java.nio.file.*; import java.util.*; import java.util.concurrent.*; import javafx.animation.*; import javafx.application.Platform; import javafx.scene.control.*; import javafx.scene.image.*; import javafx.scene.layout.*; import javafx.util.Duration; /** One shared Kit UI. Both hosts own the authoritative DocumentSession and native history. */ public final class DesignerPane extends BorderPane implements AutoCloseable { private final HostServices host; private final HostLaunch launch; private final Path file; private final KitView kit; private final Label status = new Label("Opening document"); private final TextArea source = new TextArea(), output = new TextArea(); private final ComboBox examples = new ComboBox<>(); private final SplitPane design; private final ContentPanelController content; public ContentPanelController content() { return content; } private final VBox componentInspector = new VBox(4); private void componentSelection() { componentInspector.getChildren().clear(); if (projection == null) return; var hit = kit.editor().getSelection().getHitItem(); if (hit == null) return; String id = hit.getFxId(); if (id == null || !projection.components().containsKey(id)) return; componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id)); componentInspector .getChildren() .add(new Label("Declared FXML properties (validated by target runtime)")); projection .componentAttributes(id) .forEach( (name, value) -> { var field = new TextField(value); field.setId("component-property-" + name); field.setOnAction( e -> { try { submit( projection.componentAttribute(id, name, field.getText()), "Edit Component " + name); } catch (Exception error) { report(error); } }); componentInspector.getChildren().add(new HBox(4, new Label(name), field)); }); } private final Timeline poll; private final PauseTransition typing = new PauseTransition(Duration.millis(250)), resize = new PauseTransition(Duration.millis(350)); private String submittedSource; private List pendingVisual; private String pendingBase; private static String apply(String text, List edits) { return new EditBatch(0, "projection", edits) .applyTo( new DocumentSnapshot(java.net.URI.create("file:/projection"), 0, text, true, false)); } private static List difference(String before, String after) { int start = 0, end = before.length(), last = after.length(); while (start < end && start < last && before.charAt(start) == after.charAt(start)) start++; while (end > start && last > start && before.charAt(end - 1) == after.charAt(last - 1)) { end--; last--; } return start == end && start == last ? List.of() : List.of(new EditBatch.Replacement(start, end, after.substring(start, last))); } private static int mapped(int offset, List edits) { int delta = 0; var sorted = new ArrayList<>(edits); sorted.sort(Comparator.comparingInt(EditBatch.Replacement::start)); for (var edit : sorted) { if (offset < edit.start()) break; if (offset <= edit.end()) return edit.start() + delta + Math.min(offset - edit.start(), edit.text().length()); delta += edit.text().length() - (edit.end() - edit.start()); } return offset + delta; } private DocumentSession document; private DocumentSnapshot draft; private FxmlProjection projection; private final Path reactorRoot; private DesignerWorkflow workflow; private DesignerWorkflow interactiveWorkflow; private boolean interactiveRequested, interactiveRestart; private long interactiveRequest; private final java.util.concurrent.atomic.AtomicLong draftChanges = new java.util.concurrent.atomic.AtomicLong(); private final Label interactiveStatus = new Label(); private AutoCloseable saves; public long interactivePid() { return interactiveWorkflow.renderingPid(); } private int layoutPasses; private boolean loading, closed, pending, last, canvasCommitQueued; private long canvasGeneration; private final java.util.concurrent.atomic.AtomicLong renderSequence = new java.util.concurrent.atomic.AtomicLong(); private AutoCloseable changes; private final Map images = new HashMap<>(); private BuildRefresh.Run buildRun; public DesignerPane(HostServices host, HostLaunch launch, Path file) throws Exception { this.host = host; this.launch = launch; this.file = file; Thread.currentThread().setContextClassLoader(getClass().getClassLoader()); kit = new KitView(host); content = new ContentPanelController(kit.editor()); design = new SplitPane( new HierarchyPanelController(kit.editor()).getPanelRoot(), content.getPanelRoot(), new InspectorPanelController(kit.editor()).getPanelRoot()); design.setDividerPositions(.18, .72); kit.editor().getSelection().revisionProperty().addListener((o, a, b) -> componentSelection()); workflow = new DesignerWorkflow( host, launch.configuration(), launch.context(), launch.maven(), launch.tools(), this::log); interactiveWorkflow = new DesignerWorkflow( host, launch.configuration(), launch.context(), launch.maven(), launch.tools(), this::log); interactiveWorkflow.onInteractiveClosed( () -> fx( () -> { interactiveRequested = false; interactiveRequest++; interactiveStatus.setText("Interactive preview closed"); })); Path reactor = launch.configuration().getParent().toRealPath(); reactorRoot = reactor; changes = host.documents() .onChange( uri -> { if (Path.of(uri).startsWith(reactor)) { draftChanges.incrementAndGet(); renderSequence.incrementAndGet(); workflow.cancel(); fx( () -> { if (interactiveRequested) interactiveStatus.setText( "Interactive preview uses earlier drafts; rerun or save to" + " restart"); }); fx( () -> { layoutPasses = 0; resize.playFromStart(); }); } }); saves = host.documents() .onSave( uri -> { if (Path.of(uri).startsWith(reactorRoot)) fx( () -> { if (interactiveRequested) { interactiveRestart = true; interactiveRequest++; interactiveWorkflow.cancel(); } resize.playFromStart(); }); }); var bar = new ToolBar( button("Build and Refresh", () -> build(Map.of())), button( "Cancel", () -> { if (buildRun != null) buildRun.cancel(); workflow.cancel(); renderSequence.incrementAndGet(); }), examples, button( "Render drafts", () -> { layoutPasses = 0; render(); }), button( "Interactive preview", () -> { interactiveRequested = true; interactiveRequest++; interactiveRestart = true; interactiveWorkflow.cancel(); render(); }), button( "Stop preview", () -> { interactiveRequested = false; interactiveRequest++; interactiveRestart = false; interactiveStatus.setText(""); interactiveWorkflow.cancel(); }), button("Preview last successful", () -> discover(true)), button( "Save", () -> withDraft(() -> document.save(draft.revision()).whenComplete(this::updated))), button("Undo", () -> history(false)), button("Redo", () -> history(true)), button("Open Component", this::openComponent), button("Open Row Component", this::openRowComponent), button( "Open Java", () -> { var selected = kit.editor().getSelection().getHitItem(); if (selected != null && projection != null && projection.navigationTypes().containsKey(selected.getFxId())) workflow .javaSource(projection.navigationTypes().get(selected.getFxId())) .thenCompose( uri -> host.navigation().open(new HostServices.Location(uri, 0))) .exceptionally( error -> { fx(() -> report(error)); return null; }); })); examples.setConverter( new javafx.util.StringConverter<>() { public String toString(DesignerWorkflow.Example value) { return value == null ? "Select example" : value.label(); } public DesignerWorkflow.Example fromString(String value) { return null; } }); var split = new SplitPane(source, design); split.setDividerPositions(.3); var modes = new ToolBar( button("Source", () -> split.getItems().setAll(source)), button("Design", () -> split.getItems().setAll(design)), button( "Split", () -> { split.getItems().setAll(source, design); split.setDividerPositions(.3); })); var center = new BorderPane(split); center.setTop(modes); var properties = new ScrollPane(componentInspector); properties.setFitToWidth(true); properties.setPrefViewportHeight(100); center.setBottom(properties); output.setEditable(false); output.setPrefRowCount(5); Path origin = file.resolveSibling(file.getFileName() + ".origin"); setTop( java.nio.file.Files.isRegularFile(origin) ? new VBox( new Label("Read-only origin: " + java.nio.file.Files.readString(origin)), bar) : bar); setCenter(center); setBottom(new VBox(status, interactiveStatus, output)); source .textProperty() .addListener( (o, a, b) -> { if (!loading) { flushCanvas(); if (pending && submittedSource == null) submittedSource = pendingBase; 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)) { // Source typing can arrive before an asynchronous visual command completes. // Rebase disjoint visual ranges; an overlapping later source edit wins explicitly // in the next host command, whose Undo still restores the visual command. if (submittedSource.equals(pendingBase) && pendingVisual != null) { var typed = difference(pendingBase, source.getText()); var rebased = new ArrayList(); for (var visual : pendingVisual) { if (typed.stream() .noneMatch(t -> t.start() <= visual.end() && visual.start() <= t.end())) rebased.add( new EditBatch.Replacement( mapped(visual.start(), typed), mapped(visual.end(), typed), visual.text())); } int anchor = source.getAnchor(), caret = source.getCaretPosition(); loading = true; try { rebased.sort(Comparator.comparingInt(EditBatch.Replacement::start).reversed()); for (var edit : rebased) source.replaceText(edit.start(), edit.end(), edit.text()); source.selectRange(mapped(anchor, rebased), mapped(caret, rebased)); } finally { loading = false; } } draft = value; submittedSource = null; commitSource(); return; } submittedSource = null; load(value); }); } private void load(DocumentSnapshot value) { if (closed) return; loading = true; layoutPasses = 0; canvasCommitQueued = false; canvasGeneration++; renderSequence.incrementAndGet(); workflow.cancel(); try { var hit = kit.editor().getSelection().getHitItem(); String selectedId = hit == null ? null : hit.getFxId(); draft = value; if (!source.getText().equals(value.text())) { int anchor = source.getAnchor(), caret = source.getCaretPosition(); var edits = pendingVisual != null && source.getText().equals(pendingBase) && apply(pendingBase, pendingVisual).equals(value.text()) ? pendingVisual : difference(source.getText(), value.text()); var descending = new ArrayList<>(edits); descending.sort(Comparator.comparingInt(EditBatch.Replacement::start).reversed()); for (var edit : descending) source.replaceText(edit.start(), edit.end(), edit.text()); source.selectRange(mapped(anchor, edits), mapped(caret, edits)); } pendingVisual = null; 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()); componentInspector.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(); }); } } if (selectedId != null) { var selected = new FXOMFxIdIndex(kit.editor().getFxomDocument()).lookup(selectedId); if (selected != null) kit.editor().getSelection().select(selected); } componentSelection(); status.setText( "Context " + launch.context() + " · document revision " + value.revision() + (value.dirty() ? " · unsaved" : "")); } catch (Throwable error) { design.setDisable(true); componentInspector.setDisable(true); projection = null; 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; submit(difference(draft.text(), text), name); } private void submit(List edits, String name) { if (draft == null || pending || edits.isEmpty()) return; pending = true; pendingVisual = edits; pendingBase = draft.text(); document.apply(new EditBatch(draft.revision(), name, edits)).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 || canvasCommitQueued) return; // Kit's corner-resize gesture pushes one job per dimension on the same FX event turn. // Capture the completed gesture, not the intermediate width-only document. canvasCommitQueued = true; long generation = ++canvasGeneration; var projectedDocument = kit.editor().getFxomDocument(); var expectedDraft = draft; Platform.runLater( () -> { if (generation != canvasGeneration || closed) return; if (projectedDocument != kit.editor().getFxomDocument() || draft != expectedDraft) { canvasCommitQueued = false; return; } flushCanvas(); }); } private void flushCanvas() { if (!canvasCommitQueued) return; canvasCommitQueued = false; canvasGeneration++; if (loading || pending || closed) return; try { submit( projection.edits(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; flushCanvas(); if (pending || !source.getText().equals(draft.text())) { commitSource(); status.setText("Finishing source edit; repeat the action when synchronized"); return; } action.run(); } private void history(boolean redo) { withDraft(() -> (redo ? document.redo() : document.undo()).whenComplete(this::updated)); } private void build(Map approvals) { withDraft( () -> { renderSequence.incrementAndGet(); status.setText("Building selected context…"); buildRun = workflow.build(approvals); buildRun .completion() .whenComplete( (result, error) -> fx( () -> { if (error != null) { report(error); return; } status.setText(result.message()); if (result.status() == BuildRefresh.Status.NEEDS_SAVE) { var alert = new Alert( Alert.AlertType.CONFIRMATION, "Save these build inputs?\n" + String.join( "\n", result.unsaved().stream() .map(d -> d.document().toString()) .toList()), ButtonType.OK, ButtonType.CANCEL); alert.setHeaderText("Save affected files and build"); if (alert.showAndWait().orElse(ButtonType.CANCEL) == ButtonType.OK) { var approved = new HashMap(); result .unsaved() .forEach(d -> approved.put(d.document(), d.revision())); build(approved); } } else if (result.status() == BuildRefresh.Status.READY) discover(false); })); }); } public void discover(boolean useLast) { withDraft( () -> { last = useLast; status.setText( useLast ? "Discovering explicitly retained build…" : "Discovering successful build…"); workflow .examplesFor(useLast, file.toUri()) .whenComplete( (entries, error) -> fx( () -> { if (error != null) { report(error); return; } examples.getItems().setAll(entries); if (!entries.isEmpty()) { String preferred = de.neitzel.fxdesigner.config.ConfigurationFiles.load( launch.configuration()) .contexts() .get(launch.context()) .preview() .defaultExample(); examples .getSelectionModel() .select( entries.stream() .filter(e -> e.id().equals(preferred)) .findFirst() .orElse(entries.getFirst())); } status.setText( (last ? "Outdated build explicitly selected · " : "") + entries.size() + " examples"); render(); })); }); } public void render() { withDraft( () -> { var selected = examples.getValue(); if (selected == null || projection == null) return; long ticket = renderSequence.incrementAndGet(); host.documents() .snapshots() .whenComplete( (dirty, error) -> fx( () -> { if (error != null) { report(error); return; } if (ticket != renderSequence.get()) return; var drafts = new LinkedHashMap(); drafts.put(draft.document(), draft); dirty.stream() .filter( d -> d.document().toString().endsWith(".fxml") && !Path.of(d.document()) .startsWith( launch .configuration() .getParent() .resolve(".neitzel-fx-designer")) && Path.of(d.document()).startsWith(reactorRoot)) .forEach(d -> drafts.put(d.document(), d)); drafts.put( draft.document(), new DocumentSnapshot( draft.document(), draft.revision(), projection.previewText(), draft.writable(), draft.dirty())); renderComponents(List.copyOf(drafts.values()), selected.id(), ticket); })); }); } private void renderComponents(List drafts, String example, long ticket) { if (ticket != renderSequence.get() || closed) return; var nodes = new LinkedHashMap(); for (String id : projection.components().keySet()) { var region = proxy(id); if (region == null) continue; int width = (int) Math.max( 1, Math.min( 1600, region.getWidth() > 0 ? region.getWidth() : region.prefWidth(-1))), height = (int) Math.max( 1, Math.min( 1200, region.getHeight() > 0 ? region.getHeight() : region.prefHeight(-1))); nodes.put(id, new DesignerWorkflow.Size(width, height)); region.updating(); } if (interactiveRequested && interactiveRestart) { interactiveRestart = false; long request = ++interactiveRequest, changes = draftChanges.get(); interactiveStatus.setText("Starting interactive preview of current drafts…"); interactiveWorkflow .interactive(last, example, drafts) .whenComplete( (value, error) -> fx( () -> { if (!interactiveRequested || request != interactiveRequest) return; if (error != null) { interactiveStatus.setText( "Interactive preview failed; use Interactive preview to retry"); report(error); } else interactiveStatus.setText( changes == draftChanges.get() ? "Interactive preview running from captured drafts" : "Interactive preview uses earlier drafts; rerun or save to" + " restart"); })); } if (nodes.isEmpty()) { status.setText("No embedded Components; use Interactive preview for this View"); return; } workflow .renderImages(last, example, nodes, drafts) .whenComplete( (frames, error) -> fx( () -> { if (ticket != renderSequence.get()) return; if (error != null) { projection .components() .keySet() .forEach( id -> { var region = proxy(id); if (region != null) region.failed(); }); report(error); return; } boolean measurementsChanged = false; for (var frame : frames) { int width = frame.width(), height = frame.height(); var image = new WritableImage(width, height); 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) { if (layoutPasses < 8) measurementsChanged |= region.measurements(frame.metrics()); region.show(image, last ? "Explicit retained build" : "Target runtime"); } } layoutPasses = measurementsChanged ? layoutPasses + 1 : 0; if (layoutPasses >= 8) { resize.stop(); projection .components() .keySet() .forEach( id -> { var region = proxy(id); if (region != null) region.failed(); }); status.setText("Target layout did not stabilize; retaining last layout"); return; } status.setText( "Rendered snapshot " + 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.navigationTypes().get(id); navigateComponent(type); }); } private void openRowComponent() { if (projection == null || projection.rowComponents().isEmpty()) { status.setText("No declared Row Component in this View"); return; } var rows = projection.rowComponents(); var choice = new ChoiceDialog<>(rows.iterator().next(), rows); choice.setHeaderText("Open Row Component without changing its layout"); choice.showAndWait().ifPresent(this::navigateComponent); } private void navigateComponent(String type) { host.navigation() .componentSources(type) .thenCompose( attached -> { if (attached.isEmpty()) return workflow.componentSources(type); return java.util.concurrent.CompletableFuture.completedFuture( attached.stream() .map( location -> new DesignerWorkflow.ComponentSource( location.document(), location.document().toString(), true)) .toList()); }) .whenComplete( (sources, error) -> fx( () -> { if (error != null) { report(error); return; } DesignerWorkflow.ComponentSource selected; if (sources.size() == 1) selected = sources.getFirst(); else { var choice = new ChoiceDialog<>(sources.getFirst(), sources); choice.setHeaderText("Choose Component source origin"); selected = choice.showAndWait().orElse(null); } if (selected != null) host.navigation() .open(new HostServices.Location(selected.document(), 0)) .exceptionally( failure -> { fx(() -> report(failure)); return null; }); })); } public CompletionStage flush() { var result = new CompletableFuture(); flushInto(result, System.nanoTime() + TimeUnit.SECONDS.toNanos(5)); return result; } private void flushInto(CompletableFuture result, long deadline) { flushCanvas(); 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(); saves.close(); } catch (Exception ignored) { } poll.stop(); typing.stop(); resize.stop(); workflow.close(); interactiveWorkflow.close(); if (document != null) document.close(); } }