Connect minimal hosts to snapshots, target workers and shared Kit editing
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user