Preserve FXML source ranges and extend shared Component projection
This commit is contained in:
@@ -28,10 +28,83 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
private final TextArea source = new TextArea(), output = new TextArea();
|
||||
private final ComboBox<DesignerWorkflow.Example> examples = new ComboBox<>();
|
||||
private final SplitPane design;
|
||||
private final ContentPanelController content;
|
||||
|
||||
public ContentPanelController content() {
|
||||
return content;
|
||||
}
|
||||
|
||||
private final VBox componentInspector = new VBox(4);
|
||||
|
||||
private void componentSelection() {
|
||||
componentInspector.getChildren().clear();
|
||||
if (projection == null) return;
|
||||
var hit = kit.editor().getSelection().getHitItem();
|
||||
if (hit == null) return;
|
||||
String id = hit.getFxId();
|
||||
if (id == null || !projection.components().containsKey(id)) return;
|
||||
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
|
||||
componentInspector
|
||||
.getChildren()
|
||||
.add(new Label("Declared FXML properties (validated by target runtime)"));
|
||||
projection
|
||||
.componentAttributes(id)
|
||||
.forEach(
|
||||
(name, value) -> {
|
||||
var field = new TextField(value);
|
||||
field.setId("component-property-" + name);
|
||||
field.setOnAction(
|
||||
e -> {
|
||||
try {
|
||||
submit(
|
||||
projection.componentAttribute(id, name, field.getText()),
|
||||
"Edit Component " + name);
|
||||
} catch (Exception error) {
|
||||
report(error);
|
||||
}
|
||||
});
|
||||
componentInspector.getChildren().add(new HBox(4, new Label(name), field));
|
||||
});
|
||||
}
|
||||
|
||||
private final Timeline poll;
|
||||
private final PauseTransition typing = new PauseTransition(Duration.millis(250)),
|
||||
resize = new PauseTransition(Duration.millis(350));
|
||||
private String submittedSource;
|
||||
private List<EditBatch.Replacement> pendingVisual;
|
||||
private String pendingBase;
|
||||
|
||||
private static String apply(String text, List<EditBatch.Replacement> edits) {
|
||||
return new EditBatch(0, "projection", edits)
|
||||
.applyTo(
|
||||
new DocumentSnapshot(java.net.URI.create("file:/projection"), 0, text, true, false));
|
||||
}
|
||||
|
||||
private static List<EditBatch.Replacement> difference(String before, String after) {
|
||||
int start = 0, end = before.length(), last = after.length();
|
||||
while (start < end && start < last && before.charAt(start) == after.charAt(start)) start++;
|
||||
while (end > start && last > start && before.charAt(end - 1) == after.charAt(last - 1)) {
|
||||
end--;
|
||||
last--;
|
||||
}
|
||||
return start == end && start == last
|
||||
? List.of()
|
||||
: List.of(new EditBatch.Replacement(start, end, after.substring(start, last)));
|
||||
}
|
||||
|
||||
private static int mapped(int offset, List<EditBatch.Replacement> edits) {
|
||||
int delta = 0;
|
||||
var sorted = new ArrayList<>(edits);
|
||||
sorted.sort(Comparator.comparingInt(EditBatch.Replacement::start));
|
||||
for (var edit : sorted) {
|
||||
if (offset < edit.start()) break;
|
||||
if (offset <= edit.end())
|
||||
return edit.start() + delta + Math.min(offset - edit.start(), edit.text().length());
|
||||
delta += edit.text().length() - (edit.end() - edit.start());
|
||||
}
|
||||
return offset + delta;
|
||||
}
|
||||
|
||||
private DocumentSession document;
|
||||
private DocumentSnapshot draft;
|
||||
private FxmlProjection projection;
|
||||
@@ -49,12 +122,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
this.file = file;
|
||||
Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
|
||||
kit = new KitView(host);
|
||||
content = new ContentPanelController(kit.editor());
|
||||
design =
|
||||
new SplitPane(
|
||||
new HierarchyPanelController(kit.editor()).getPanelRoot(),
|
||||
new ContentPanelController(kit.editor()).getPanelRoot(),
|
||||
content.getPanelRoot(),
|
||||
new InspectorPanelController(kit.editor()).getPanelRoot());
|
||||
design.setDividerPositions(.18, .72);
|
||||
kit.editor().getSelection().revisionProperty().addListener((o, a, b) -> componentSelection());
|
||||
workflow =
|
||||
new DesignerWorkflow(
|
||||
host,
|
||||
@@ -102,21 +177,35 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
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);
|
||||
var modes =
|
||||
new ToolBar(
|
||||
button("Source", () -> split.getItems().setAll(source)),
|
||||
button("Design", () -> split.getItems().setAll(design)),
|
||||
button(
|
||||
"Split",
|
||||
() -> {
|
||||
split.getItems().setAll(source, design);
|
||||
split.setDividerPositions(.3);
|
||||
}));
|
||||
var center = new BorderPane(split);
|
||||
center.setTop(modes);
|
||||
var properties = new ScrollPane(componentInspector);
|
||||
properties.setFitToWidth(true);
|
||||
properties.setPrefViewportHeight(100);
|
||||
center.setBottom(properties);
|
||||
output.setEditable(false);
|
||||
output.setPrefRowCount(5);
|
||||
setTop(bar);
|
||||
setCenter(tabs);
|
||||
setCenter(center);
|
||||
setBottom(new VBox(status, output));
|
||||
source
|
||||
.textProperty()
|
||||
.addListener(
|
||||
(o, a, b) -> {
|
||||
if (!loading) {
|
||||
if (pending && submittedSource == null) submittedSource = pendingBase;
|
||||
commitSource();
|
||||
workflow.cancel();
|
||||
renderSequence.incrementAndGet();
|
||||
@@ -211,6 +300,31 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
return;
|
||||
}
|
||||
if (submittedSource != null && !source.getText().equals(submittedSource)) {
|
||||
// Source typing can arrive before an asynchronous visual command completes.
|
||||
// Rebase disjoint visual ranges; an overlapping later source edit wins explicitly
|
||||
// in the next host command, whose Undo still restores the visual command.
|
||||
if (submittedSource.equals(pendingBase) && pendingVisual != null) {
|
||||
var typed = difference(pendingBase, source.getText());
|
||||
var rebased = new ArrayList<EditBatch.Replacement>();
|
||||
for (var visual : pendingVisual) {
|
||||
if (typed.stream()
|
||||
.noneMatch(t -> t.start() <= visual.end() && visual.start() <= t.end()))
|
||||
rebased.add(
|
||||
new EditBatch.Replacement(
|
||||
mapped(visual.start(), typed),
|
||||
mapped(visual.end(), typed),
|
||||
visual.text()));
|
||||
}
|
||||
int anchor = source.getAnchor(), caret = source.getCaretPosition();
|
||||
loading = true;
|
||||
try {
|
||||
rebased.sort(Comparator.comparingInt(EditBatch.Replacement::start).reversed());
|
||||
for (var edit : rebased) source.replaceText(edit.start(), edit.end(), edit.text());
|
||||
source.selectRange(mapped(anchor, rebased), mapped(caret, rebased));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
draft = value;
|
||||
submittedSource = null;
|
||||
commitSource();
|
||||
@@ -227,8 +341,23 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
renderSequence.incrementAndGet();
|
||||
workflow.cancel();
|
||||
try {
|
||||
var hit = kit.editor().getSelection().getHitItem();
|
||||
String selectedId = hit == null ? null : hit.getFxId();
|
||||
draft = value;
|
||||
if (!source.getText().equals(value.text())) source.setText(value.text());
|
||||
if (!source.getText().equals(value.text())) {
|
||||
int anchor = source.getAnchor(), caret = source.getCaretPosition();
|
||||
var edits =
|
||||
pendingVisual != null
|
||||
&& source.getText().equals(pendingBase)
|
||||
&& apply(pendingBase, pendingVisual).equals(value.text())
|
||||
? pendingVisual
|
||||
: difference(source.getText(), value.text());
|
||||
var descending = new ArrayList<>(edits);
|
||||
descending.sort(Comparator.comparingInt(EditBatch.Replacement::start).reversed());
|
||||
for (var edit : descending) source.replaceText(edit.start(), edit.end(), edit.text());
|
||||
source.selectRange(mapped(anchor, edits), mapped(caret, edits));
|
||||
}
|
||||
pendingVisual = null;
|
||||
source.setEditable(value.writable());
|
||||
// End an inspector editing session before replacing FXOM (also after native undo).
|
||||
if (getScene() != null
|
||||
@@ -238,6 +367,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
String projected = projection.project();
|
||||
kit.editor().setFxmlTextAndLocation(projected, file.toUri().toURL());
|
||||
design.setDisable(!value.writable());
|
||||
componentInspector.setDisable(!value.writable());
|
||||
kit.editor()
|
||||
.getFxomDocument()
|
||||
.sceneGraphRevisionProperty()
|
||||
@@ -261,6 +391,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (selectedId != null) {
|
||||
var selected = new FXOMFxIdIndex(kit.editor().getFxomDocument()).lookup(selectedId);
|
||||
if (selected != null) kit.editor().getSelection().select(selected);
|
||||
}
|
||||
componentSelection();
|
||||
status.setText(
|
||||
"Context "
|
||||
+ launch.context()
|
||||
@@ -269,6 +404,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
+ (value.dirty() ? " · unsaved" : ""));
|
||||
} catch (Throwable error) {
|
||||
design.setDisable(true);
|
||||
componentInspector.setDisable(true);
|
||||
projection = null;
|
||||
report(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
@@ -285,14 +422,15 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
|
||||
private void replace(String text, String name) {
|
||||
if (draft == null || pending) return;
|
||||
submit(difference(draft.text(), text), name);
|
||||
}
|
||||
|
||||
private void submit(List<EditBatch.Replacement> edits, String name) {
|
||||
if (draft == null || pending || edits.isEmpty()) return;
|
||||
pending = true;
|
||||
document
|
||||
.apply(
|
||||
new EditBatch(
|
||||
draft.revision(),
|
||||
name,
|
||||
List.of(new EditBatch.Replacement(0, draft.text().length(), text))))
|
||||
.whenComplete(this::updated);
|
||||
pendingVisual = edits;
|
||||
pendingBase = draft.text();
|
||||
document.apply(new EditBatch(draft.revision(), name, edits)).whenComplete(this::updated);
|
||||
}
|
||||
|
||||
private void commitSource() {
|
||||
@@ -305,8 +443,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
private void canvasChanged() {
|
||||
if (loading || pending || closed) return;
|
||||
try {
|
||||
replace(
|
||||
projection.restore(kit.editor().getFxomDocument().getFxmlText(false)),
|
||||
submit(
|
||||
projection.edits(kit.editor().getFxomDocument().getFxmlText(false)),
|
||||
"Component layout / property edit");
|
||||
} catch (Throwable error) {
|
||||
report(error);
|
||||
@@ -439,6 +577,14 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
||||
&& Path.of(d.document())
|
||||
.startsWith(launch.configuration().getParent()))
|
||||
.forEach(d -> drafts.put(d.document(), d));
|
||||
drafts.put(
|
||||
draft.document(),
|
||||
new DocumentSnapshot(
|
||||
draft.document(),
|
||||
draft.revision(),
|
||||
projection.previewText(),
|
||||
draft.writable(),
|
||||
draft.dirty()));
|
||||
renderComponents(List.copyOf(drafts.values()), selected.id(), ticket);
|
||||
}));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user