Preserve FXML source ranges and extend shared Component projection

This commit is contained in:
2026-11-12 11:52:43 +01:00
parent 7db7218a6e
commit dfa2dd1508
19 changed files with 1145 additions and 49 deletions
@@ -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);
}));
});