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