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,286 @@
/*
* Copyright (c) 2016, Gluon and/or its affiliates.
* Copyright (c) 2012, 2014, Oracle and/or its affiliates.
* All rights reserved. Use is subject to license terms.
*
* This file is available and licensed under the following license:
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* - Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* - Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in
* the documentation and/or other materials provided with the distribution.
* - Neither the name of Oracle Corporation nor the names of its
* contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package com.oracle.javafx.scenebuilder.kit.editor.panel.inspector.editors;
import com.oracle.javafx.scenebuilder.kit.i18n.I18N;
import com.oracle.javafx.scenebuilder.kit.metadata.property.ValuePropertyMetadata;
import com.oracle.javafx.scenebuilder.kit.metadata.util.PrefixedValue;
import java.util.Set;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.control.TextInputControl;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
/**
* String editor with I18n + multi-line handling.
*
*
*/
public class I18nStringEditor extends PropertyEditor {
private static final String PERCENT_STR = "%"; //NOI18N
private TextInputControl textNode = new TextField();
private HBox i18nHBox = null;
private EventHandler<ActionEvent> valueListener;
private final MenuItem i18nMenuItem = new MenuItem();
private final String I18N_ON = I18N.getString("inspector.i18n.on");
private final String I18N_OFF = I18N.getString("inspector.i18n.off");
private final MenuItem multilineMenuItem = new MenuItem();
private final String MULTI_LINE = I18N.getString("inspector.i18n.multiline");
private final String SINGLE_LINE = I18N.getString("inspector.i18n.singleline");
private boolean multiLineSupported = false;
// Specific states
private boolean i18nMode = false;
private boolean multiLineMode = false;
public I18nStringEditor(ValuePropertyMetadata propMeta, Set<Class<?>> selectedClasses, boolean multiLineSupported) {
super(propMeta, selectedClasses);
initialize(multiLineSupported);
}
private void initialize(boolean multiLineSupported) {
this.multiLineSupported = multiLineSupported;
valueListener = event -> {
userUpdateValueProperty(getValue());
textNode.selectAll();
};
setTextEditorBehavior(this, textNode, valueListener);
getMenu().getItems().add(i18nMenuItem);
getMenu().getItems().add(multilineMenuItem);
i18nMenuItem.setOnAction(e -> {
if (!i18nMode) {
setValue(new PrefixedValue(PrefixedValue.Type.RESOURCE_KEY, I18N.getString("inspector.i18n.dummykey")).toString());
} else {
setValue(""); //NOI18N
}
I18nStringEditor.this.getCommitListener().handle(null);
updateMenuItems();
});
multilineMenuItem.setOnAction(e -> {
if (!multiLineMode) {
switchToTextArea();
} else {
switchToTextField();
}
multiLineMode = !multiLineMode;
updateMenuItems();
});
// Neitzel FX Designer: retain native JavaFX caret, drag and word selection.
}
@Override
public Object getValue() {
String val = textNode.getText();
if (i18nMode) {
val = new PrefixedValue(PrefixedValue.Type.RESOURCE_KEY, val).toString();
} else {
val = EditorUtils.getPlainString(val);
}
return val;
}
@Override
public void setValue(Object value) {
setValueGeneric(value);
if (isSetValueDone()) {
return;
}
if (value == null) {
textNode.setText(null);
return;
}
assert value instanceof String;
String val = (String) value;
PrefixedValue prefixedValue = new PrefixedValue(val);
String suffix = prefixedValue.getSuffix();
// Handle i18n
if (prefixedValue.isResourceKey()) {
if (!i18nMode) {
wrapInHBox();
i18nMode = true;
}
} else if (i18nMode) {
// no percent + i18nMode
unwrapHBox();
i18nMode = false;
}
// Handle multi-line
if (containsLineFeed(prefixedValue.toString())) {
if (i18nMode) {
// multi-line + i18n ==> set as i18n only
multiLineMode = false;
switchToTextField();
} else {
if (!multiLineMode) {
multiLineMode = true;
switchToTextArea();
}
}
} else {
// no line feed
if (multiLineMode) {
multiLineMode = false;
switchToTextField();
}
}
if (i18nMode) {
textNode.setText(suffix);
} else {
// We may have other special characters (@, $, ...) to display in the text field
textNode.setText(prefixedValue.toString());
}
updateMenuItems();
}
public void reset(ValuePropertyMetadata propMeta, Set<Class<?>> selectedClasses, boolean multiLineSupported) {
super.reset(propMeta, selectedClasses);
this.multiLineSupported = multiLineSupported;
textNode.setPromptText(null);
}
@Override
public Node getValueEditor() {
Node valueEditor;
if (i18nMode) {
valueEditor = i18nHBox;
} else {
valueEditor = textNode;
}
return super.handleGenericModes(valueEditor);
}
@Override
protected void valueIsIndeterminate() {
handleIndeterminate(textNode);
}
protected void switchToTextArea() {
if (textNode instanceof TextArea) {
return;
}
// Move the node from TextField to TextArea
TextArea textArea = new TextArea(textNode.getText());
setTextEditorBehavior(this, textArea, valueListener);
textArea.setPrefRowCount(5);
setLayoutFormat(LayoutFormat.SIMPLE_LINE_TOP);
if (textNode.getParent() != null) {
// textNode is already in scene graph
EditorUtils.replaceNode(textNode, textArea, getLayoutFormat());
}
textNode = textArea;
}
protected void switchToTextField() {
if (textNode instanceof TextField) {
return;
}
// Move the node from TextArea to TextField.
// The current text is compacted to a single line.
String val = textNode.getText().replace("\n", "");//NOI18N
TextField textField = new TextField(val);
setTextEditorBehavior(this, textField, valueListener);
setLayoutFormat(LayoutFormat.SIMPLE_LINE_CENTERED);
if (textNode.getParent() != null) {
// textNode is already in scene graph
EditorUtils.replaceNode(textNode, textField, getLayoutFormat());
}
textNode = textField;
}
private void wrapInHBox() {
i18nHBox = new HBox();
i18nHBox.setAlignment(Pos.CENTER);
EditorUtils.replaceNode(textNode, i18nHBox, null);
Label percentLabel = new Label(PERCENT_STR);
percentLabel.getStyleClass().add("symbol-prefix"); //NOI18N
i18nHBox.getChildren().addAll(percentLabel, textNode);
HBox.setHgrow(percentLabel, Priority.NEVER);
// we have to set a small pref width for the text node else it will
// revert to it's API set pref width which is too wide
textNode.setPrefWidth(30.0);
HBox.setHgrow(textNode, Priority.ALWAYS);
}
private void unwrapHBox() {
i18nHBox.getChildren().remove(textNode);
EditorUtils.replaceNode(i18nHBox, textNode, null);
}
private static boolean containsLineFeed(String str) {
return str.contains("\n"); //NOI18N
}
@Override
public void requestFocus() {
EditorUtils.doNextFrame(() -> textNode.requestFocus());
}
private void updateMenuItems() {
if (i18nMode) {
i18nMenuItem.setText(I18N_OFF);
multilineMenuItem.setDisable(true);
} else {
i18nMenuItem.setText(I18N_ON);
multilineMenuItem.setDisable(false);
}
if (multiLineMode) {
multilineMenuItem.setText(SINGLE_LINE);
i18nMenuItem.setDisable(true);
} else {
multilineMenuItem.setText(MULTI_LINE);
i18nMenuItem.setDisable(false);
}
if (!multiLineSupported) {
multilineMenuItem.setDisable(true);
}
}
}
@@ -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();
}
}
@@ -0,0 +1,204 @@
package de.neitzel.fxdesigner.kit;
import java.io.*;
import java.util.*;
import javax.xml.parsers.*;
import javax.xml.transform.*;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.w3c.dom.*;
/**
* Bounded scalar/layout projection. Original XML owns bindings, controllers and unknown metadata.
*/
public final class FxmlProjection {
private static final String FX = "http://javafx.com/fxml/1";
private static final Set<String> EDITABLE =
Set.of(
"caption",
"text",
"prefWidth",
"prefHeight",
"minWidth",
"minHeight",
"maxWidth",
"maxHeight",
"layoutX",
"layoutY",
"visible",
"managed",
"disable",
"opacity",
"alignment",
"spacing",
"hgap",
"vgap",
"GridPane.columnIndex",
"GridPane.rowIndex",
"GridPane.hgrow",
"GridPane.vgrow");
private final Document original;
private Document baseline;
private final Map<String, String> components = new LinkedHashMap<>();
public FxmlProjection(String text) throws Exception {
original = parse(text);
}
static Document parse(String text) throws Exception {
var f = DocumentBuilderFactory.newInstance();
f.setNamespaceAware(true);
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setXIncludeAware(false);
f.setExpandEntityReferences(false);
return f.newDocumentBuilder().parse(new org.xml.sax.InputSource(new StringReader(text)));
}
static String xml(Document d) throws Exception {
var t = TransformerFactory.newInstance().newTransformer();
var w = new StringWriter();
t.transform(new DOMSource(d), new StreamResult(w));
return w.toString();
}
public Map<String, String> components() {
return Map.copyOf(components);
}
public String project() throws Exception {
var d = (Document) original.cloneNode(true);
var imports = new HashMap<String, String>();
var remove = new ArrayList<Node>();
for (Node n = d.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import")) {
String type = pi.getData().trim();
if (!type.startsWith("javafx.")) {
if (type.endsWith(".*"))
throw new IOException("Application wildcard imports need an explicit Component import");
imports.put(type.substring(type.lastIndexOf('.') + 1), type);
remove.add(n);
}
}
remove.forEach(d::removeChild);
d.insertBefore(
d.createProcessingInstruction("import", SnapshotRegion.class.getName()),
d.getDocumentElement());
var definitions = d.getElementsByTagNameNS(FX, "define");
while (definitions.getLength() > 0) {
Node n = definitions.item(0);
n.getParentNode().removeChild(n);
}
var nodes = d.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
String tag = e.getTagName();
if (FX.equals(e.getNamespaceURI()))
throw new IOException("This minimal projection does not support " + tag);
if (imports.containsKey(tag)) {
String id = e.getAttributeNS(FX, "id");
if (id.isBlank()) throw new IOException("Component projection requires fx:id");
components.put(id, imports.get(tag));
if (e.hasChildNodes())
throw new IOException("Inline Component content is outside this projection");
d.renameNode(e, null, "SnapshotRegion");
var attrs = e.getAttributes();
for (int a = attrs.getLength() - 1; a >= 0; a--) {
var attr = (Attr) attrs.item(a);
if (!EDITABLE.contains(attr.getName()) && !attr.getName().equals("fx:id"))
e.removeAttributeNode(attr);
}
} else if (Character.isUpperCase(tag.charAt(0))
&& tag.contains(".")
&& !tag.startsWith("javafx."))
throw new IOException("Unsupported application element: " + tag);
e.removeAttributeNS(FX, "controller");
var attrs = e.getAttributes();
for (int a = attrs.getLength() - 1; a >= 0; a--) {
var attr = (Attr) attrs.item(a);
if (attr.getValue().startsWith("#")
|| attr.getValue().startsWith("$")
|| attr.getName().startsWith("on")) e.removeAttributeNode(attr);
}
}
baseline = (Document) d.cloneNode(true);
return xml(d);
}
private static Map<String, Element> indexed(Document d) throws IOException {
var result = new LinkedHashMap<String, Element>();
result.put("@root", d.getDocumentElement());
var nodes = d.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
String id = e.getAttributeNS(FX, "id");
if (!id.isBlank() && result.putIfAbsent(id, e) != null)
throw new IOException("Duplicate fx:id");
}
return result;
}
private static java.util.List<String> structure(Document document) {
var result = new java.util.ArrayList<String>();
var objects = new IdentityHashMap<Node, Integer>();
var nodes = document.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
String tag = e.getTagName();
if (Character.isUpperCase(tag.charAt(0))) {
Node parent = e.getParentNode();
while (parent != null && !objects.containsKey(parent)) parent = parent.getParentNode();
var fixed = new TreeMap<String, String>();
if (e != document.getDocumentElement() && e.getAttributeNS(FX, "id").isBlank())
for (int a = 0; a < e.getAttributes().getLength(); a++) {
var attr = e.getAttributes().item(a);
if (!attr.getNodeName().startsWith("xmlns"))
fixed.put(attr.getNodeName(), attr.getNodeValue());
}
result.add(
tag
+ ":"
+ e.getAttributeNS(FX, "id")
+ ":"
+ objects.getOrDefault(parent, -1)
+ ":"
+ fixed);
objects.put(e, objects.size());
}
}
return result;
}
public String restore(String projected) throws Exception {
var changed = parse(projected);
var before = indexed(baseline);
var after = indexed(changed);
var output = (Document) original.cloneNode(true);
var targets = indexed(output);
if (!before.keySet().equals(after.keySet()) || !structure(baseline).equals(structure(changed)))
throw new IOException("Structural editing is outside this minimal projection");
for (String id : before.keySet()) {
var attributes = new HashSet<String>();
for (Element element : List.of(before.get(id), after.get(id)))
for (int i = 0; i < element.getAttributes().getLength(); i++)
attributes.add(element.getAttributes().item(i).getNodeName());
for (String attr : attributes)
if (!EDITABLE.contains(attr)
&& !attr.startsWith("xmlns")
&& !before.get(id).getAttribute(attr).equals(after.get(id).getAttribute(attr)))
throw new IOException("Edit " + attr + " in source; this projection does not support it");
}
for (String id : before.keySet())
for (String attr : EDITABLE) {
String old = before.get(id).getAttribute(attr), value = after.get(id).getAttribute(attr);
if (!old.equals(value)) {
var target = targets.get(id);
String originalValue = target.getAttribute(attr);
if (originalValue.startsWith("$") || originalValue.startsWith("#"))
throw new IOException("Bound attributes must be edited in source");
if (value.isEmpty()) target.removeAttribute(attr);
else target.setAttribute(attr, value);
}
}
return xml(output);
}
}
@@ -0,0 +1,79 @@
package de.neitzel.fxdesigner.kit;
import javafx.beans.property.*;
import javafx.scene.control.Label;
import javafx.scene.image.*;
import javafx.scene.layout.Region;
/** Host-owned pixels only; never loads an application control into the host runtime. */
public final class SnapshotRegion extends Region {
private final StringProperty caption = new SimpleStringProperty(this, "caption", "");
private final ObjectProperty<javafx.geometry.Pos> alignment =
new SimpleObjectProperty<>(this, "alignment", javafx.geometry.Pos.TOP_LEFT);
public javafx.geometry.Pos getAlignment() {
return alignment.get();
}
public void setAlignment(javafx.geometry.Pos value) {
alignment.set(value);
}
public ObjectProperty<javafx.geometry.Pos> alignmentProperty() {
return alignment;
}
private final ImageView pixels = new ImageView();
private final Label status = new Label("Render preview");
public SnapshotRegion() {
getChildren().addAll(pixels, status);
pixels.setMouseTransparent(true);
status.setMouseTransparent(true);
setStyle("-fx-background-color:#edf3f8;-fx-border-color:#6b879c");
}
public String getCaption() {
return caption.get();
}
public void setCaption(String value) {
caption.set(value);
}
public StringProperty captionProperty() {
return caption;
}
public void show(Image image, String message) {
pixels.setImage(image);
status.setText(message);
requestLayout();
}
public Image image() {
return pixels.getImage();
}
public String statusText() {
return status.getText();
}
public void updating() {
status.setText("Updating…");
}
protected void layoutChildren() {
pixels.setFitWidth(getWidth());
pixels.setFitHeight(getHeight());
status.resizeRelocate(4, Math.max(0, getHeight() - 24), Math.max(0, getWidth() - 8), 24);
}
protected double computePrefWidth(double height) {
return 300;
}
protected double computePrefHeight(double width) {
return 220;
}
}
@@ -0,0 +1,104 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.contracts.*;
import de.neitzel.fxdesigner.host.*;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import javafx.application.Platform;
import javafx.scene.control.*;
import javafx.scene.layout.*;
/** Source editing stays available when standalone configuration cannot enable the designer. */
public final class SourceOnlyPane extends BorderPane implements AutoCloseable {
private final TextArea source = new TextArea();
private final Label status = new Label();
private DocumentSession document;
private DocumentSnapshot baseline;
private boolean closed;
public SourceOnlyPane(HostServices host, Path file, String reason) {
setTop(new Label("Designer unavailable: " + reason + " — correct local setup and reopen"));
setCenter(source);
var apply = new Button("Apply source");
var save = new Button("Save");
var undo = new Button("Undo");
var redo = new Button("Redo");
setBottom(new VBox(new ToolBar(apply, save, undo, redo), status));
source.setEditable(false);
apply.setOnAction(e -> apply(false));
save.setOnAction(e -> apply(true));
undo.setOnAction(
e -> {
if (document != null) update(document.undo());
});
redo.setOnAction(
e -> {
if (document != null) update(document.redo());
});
host.documents()
.open(file.toUri())
.whenComplete(
(d, error) ->
Platform.runLater(
() -> {
if (error != null) {
status.setText(error.getMessage());
return;
}
if (closed) {
d.close();
return;
}
document = d;
update(d.snapshot());
}));
}
private CompletionStage<DocumentSnapshot> apply(boolean save) {
if (document == null || baseline == null)
return CompletableFuture.failedFuture(new IllegalStateException("Document not ready"));
source.setEditable(false);
var result =
source.getText().equals(baseline.text())
? document.snapshot()
: document.apply(
new EditBatch(
baseline.revision(),
"Edit FXML source",
List.of(
new EditBatch.Replacement(0, baseline.text().length(), source.getText()))));
if (save) result = result.thenCompose(value -> document.save(value.revision()));
update(result);
return result;
}
public CompletionStage<Void> save() {
return apply(true).thenApply(value -> null);
}
public boolean dirty() {
return baseline != null && (baseline.dirty() || !source.getText().equals(baseline.text()));
}
private void update(CompletionStage<DocumentSnapshot> result) {
result.whenComplete(
(value, error) ->
Platform.runLater(
() -> {
if (closed) return;
if (error != null) status.setText(String.valueOf(error.getMessage()));
else {
baseline = value;
source.setText(value.text());
status.setText(value.dirty() ? "Unsaved draft" : "Saved");
}
source.setEditable(baseline != null && baseline.writable());
}));
}
public void close() {
closed = true;
if (document != null) document.close();
}
}
@@ -0,0 +1,31 @@
Copyright (c) 2016, 2017 Gluon and/or its affiliates.
Copyright (c) 2012, 2014, Oracle and/or its affiliates.
All rights reserved. Use is subject to license terms.
This file is available and licensed under the following license:
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the distribution.
- Neither the name of Oracle Corporation and Gluon nor the names of its
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,61 @@
package de.neitzel.fxdesigner.kit;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class FxmlProjectionTest {
private static final String XML =
"""
<?neitzel-model app.Person?>
<?import javafx.scene.layout.VBox?>
<?import app.Card?>
<VBox xmlns:fx="http://javafx.com/fxml/1" fx:controller="app.Controller">
<!-- retained -->
<fx:define><app.Binding control="card" path="home"/></fx:define>
<Card fx:id="card" caption="before" prefWidth="310" custom="preserve"/>
</VBox>
""";
@Test
void patchesOnlySupportedAttributesAndPreservesOriginalMetadata() throws Exception {
var projection = new FxmlProjection(XML);
String projected = projection.project();
assertFalse(projected.contains("app.Controller"));
assertFalse(projected.contains("app.Card"));
assertFalse(projected.contains("app.Binding"));
String restored =
projection.restore(projected.replace("caption=\"before\"", "caption=\"after\""));
assertTrue(restored.contains("caption=\"after\""));
assertTrue(restored.contains("custom=\"preserve\""));
assertTrue(restored.contains("app.Binding"));
assertTrue(restored.contains("app.Controller"));
assertTrue(restored.contains("retained"));
assertTrue(restored.contains("neitzel-model"));
}
@Test
void rejectsExternalEntitiesAndExecutableFXML() throws Exception {
assertThrows(
Exception.class,
() -> new FxmlProjection("<!DOCTYPE x SYSTEM 'file:///tmp/private'><VBox/>"));
assertThrows(
Exception.class,
() ->
new FxmlProjection(
"<VBox"
+ " xmlns:fx=\"http://javafx.com/fxml/1\"><fx:script>evil</fx:script></VBox>")
.project());
}
@Test
void rejectsStructuralEditsAndUnnamedComponents() throws Exception {
var projection = new FxmlProjection(XML);
String projected = projection.project();
assertThrows(
Exception.class,
() -> projection.restore(projected.replace("fx:id=\"card\"", "fx:id=\"different\"")));
assertThrows(
Exception.class, () -> new FxmlProjection(XML.replace("fx:id=\"card\"", "")).project());
}
}