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();
}
}