Add shared source-preserving DirectBinding inspector
This commit is contained in:
@@ -6,7 +6,7 @@ Shared Scene Builder Kit designer for NeitzelLib FX, with minimal standalone and
|
|||||||
|
|
||||||
The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits.
|
The first workflow is runnable in both hosts: Build and Refresh publishes immutable snapshots; authenticated target workers discover examples and render parent/Component FXML drafts into the shared Kit canvas. Source and supported canvas edits use host-owned documents and undo/redo. See [the connected workflow](docs/first-connected-workflow.md) for architecture, launch commands, evidence and limits.
|
||||||
|
|
||||||
The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. Binding edits, generation, structural editing and product packaging remain later work.
|
The repository builds six shared Maven modules, a Maven capture plugin and a separate IntelliJ adapter plugin. Application code runs only in target JVMs. The shared UI now applies [source-preserving edits](docs/source-preserving-projection.md), supports nested/anonymous Components and fx:root bodies, and provides Source/Design/Split modes. Retained [target sessions, target sizing, interactive preview and Component/Row navigation](docs/target-runtime-sessions.md) now extend that workflow. The shared [binding discovery browser](docs/binding-discovery.md) now lists declared Model paths and direction-aware target compatibility. The [DirectBinding inspector](docs/direct-binding-inspector.md) creates, edits and removes declarations through source-preserving host Undo/Redo. Broader binding authoring, generation, structural editing and product packaging remain later work.
|
||||||
|
|
||||||
Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats and validation rules.
|
Configuration 1.0 schemas, context selection, local JDK mappings and draft protocol validation are now executable. See [configuration and protocol](docs/configuration-and-protocol.md) for formats and validation rules.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import javafx.beans.property.ReadOnlyStringWrapper;
|
|||||||
import javafx.scene.control.*;
|
import javafx.scene.control.*;
|
||||||
import javafx.scene.layout.*;
|
import javafx.scene.layout.*;
|
||||||
|
|
||||||
/** Shared read-only first slice of the binding inspector. */
|
/** Shared target discovery with explicit handoff to the binding editor. */
|
||||||
final class BindingBrowser extends VBox {
|
final class BindingBrowser extends VBox {
|
||||||
final ComboBox<BindingMetadata.Attribute> paths = new ComboBox<>();
|
final ComboBox<BindingMetadata.Attribute> paths = new ComboBox<>();
|
||||||
final ComboBox<String> directions = new ComboBox<>();
|
final ComboBox<String> directions = new ComboBox<>();
|
||||||
@@ -14,7 +14,8 @@ final class BindingBrowser extends VBox {
|
|||||||
final Label status = new Label("Build and Refresh, then discover binding targets.");
|
final Label status = new Label("Build and Refresh, then discover binding targets.");
|
||||||
private boolean updating;
|
private boolean updating;
|
||||||
|
|
||||||
BindingBrowser(BiConsumer<String, String> discover) {
|
BindingBrowser(
|
||||||
|
BiConsumer<String, String> discover, Consumer<DirectBindingDocument.Values> create) {
|
||||||
setSpacing(5);
|
setSpacing(5);
|
||||||
paths.setId("binding-path");
|
paths.setId("binding-path");
|
||||||
directions.setId("binding-direction");
|
directions.setId("binding-direction");
|
||||||
@@ -25,6 +26,20 @@ final class BindingBrowser extends VBox {
|
|||||||
directions.setValue("both");
|
directions.setValue("both");
|
||||||
Button refresh = new Button("Discover bindings");
|
Button refresh = new Button("Discover bindings");
|
||||||
refresh.setOnAction(e -> discover.accept(path(), directions.getValue()));
|
refresh.setOnAction(e -> discover.accept(path(), directions.getValue()));
|
||||||
|
Button use = new Button("Use selected target");
|
||||||
|
use.setDisable(true);
|
||||||
|
targets
|
||||||
|
.getSelectionModel()
|
||||||
|
.selectedItemProperty()
|
||||||
|
.addListener((o, a, b) -> use.setDisable(b == null));
|
||||||
|
use.setOnAction(
|
||||||
|
e -> {
|
||||||
|
var target = targets.getSelectionModel().getSelectedItem();
|
||||||
|
if (target != null)
|
||||||
|
create.accept(
|
||||||
|
new DirectBindingDocument.Values(
|
||||||
|
target.control(), target.property(), path(), directions.getValue(), ""));
|
||||||
|
});
|
||||||
paths.setOnAction(
|
paths.setOnAction(
|
||||||
e -> {
|
e -> {
|
||||||
if (!updating) discover.accept(path(), directions.getValue());
|
if (!updating) discover.accept(path(), directions.getValue());
|
||||||
@@ -46,7 +61,8 @@ final class BindingBrowser extends VBox {
|
|||||||
new HBox(
|
new HBox(
|
||||||
8, refresh, new Label("Model path"), paths, new Label("Direction"), directions),
|
8, refresh, new Label("Model path"), paths, new Label("Direction"), directions),
|
||||||
status,
|
status,
|
||||||
targets);
|
targets,
|
||||||
|
use);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String path() {
|
private String path() {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
|||||||
var hit = kit.editor().getSelection().getHitItem();
|
var hit = kit.editor().getSelection().getHitItem();
|
||||||
if (hit == null) return;
|
if (hit == null) return;
|
||||||
String id = hit.getFxId();
|
String id = hit.getFxId();
|
||||||
|
if (bindingEditor != null) bindingEditor.selectControl(id);
|
||||||
if (id == null || !projection.components().containsKey(id)) return;
|
if (id == null || !projection.components().containsKey(id)) return;
|
||||||
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
|
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
|
||||||
componentInspector
|
componentInspector
|
||||||
@@ -113,6 +114,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
|||||||
private DesignerWorkflow interactiveWorkflow;
|
private DesignerWorkflow interactiveWorkflow;
|
||||||
private DesignerWorkflow bindingWorkflow;
|
private DesignerWorkflow bindingWorkflow;
|
||||||
private BindingBrowser bindingBrowser;
|
private BindingBrowser bindingBrowser;
|
||||||
|
private DirectBindingEditor bindingEditor;
|
||||||
|
private Tab directBindingTab;
|
||||||
private long bindingRequest;
|
private long bindingRequest;
|
||||||
private boolean interactiveRequested, interactiveRestart;
|
private boolean interactiveRequested, interactiveRestart;
|
||||||
private long interactiveRequest;
|
private long interactiveRequest;
|
||||||
@@ -172,7 +175,21 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
|||||||
launch.maven(),
|
launch.maven(),
|
||||||
launch.tools(),
|
launch.tools(),
|
||||||
this::log);
|
this::log);
|
||||||
bindingBrowser = new BindingBrowser(this::discoverBindings);
|
bindingEditor =
|
||||||
|
new DirectBindingEditor(
|
||||||
|
this::editBinding,
|
||||||
|
id -> {
|
||||||
|
if (kit.editor().getFxomDocument() == null) return;
|
||||||
|
var target = new FXOMFxIdIndex(kit.editor().getFxomDocument()).lookup(id);
|
||||||
|
if (target != null) kit.editor().getSelection().select(target);
|
||||||
|
});
|
||||||
|
bindingBrowser =
|
||||||
|
new BindingBrowser(
|
||||||
|
this::discoverBindings,
|
||||||
|
values -> {
|
||||||
|
bindingEditor.newBinding(values);
|
||||||
|
directBindingTab.getTabPane().getSelectionModel().select(directBindingTab);
|
||||||
|
});
|
||||||
interactiveWorkflow.onInteractiveClosed(
|
interactiveWorkflow.onInteractiveClosed(
|
||||||
() ->
|
() ->
|
||||||
fx(
|
fx(
|
||||||
@@ -308,7 +325,11 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
|||||||
var properties = new ScrollPane(componentInspector);
|
var properties = new ScrollPane(componentInspector);
|
||||||
properties.setFitToWidth(true);
|
properties.setFitToWidth(true);
|
||||||
properties.setPrefViewportHeight(100);
|
properties.setPrefViewportHeight(100);
|
||||||
var bindings = new TitledPane("Bindings — discovery", bindingBrowser);
|
var discoveryTab = new Tab("Discover targets", bindingBrowser);
|
||||||
|
discoveryTab.setClosable(false);
|
||||||
|
directBindingTab = new Tab("Direct bindings", bindingEditor);
|
||||||
|
directBindingTab.setClosable(false);
|
||||||
|
var bindings = new TitledPane("Bindings", new TabPane(directBindingTab, discoveryTab));
|
||||||
bindings.setExpanded(false);
|
bindings.setExpanded(false);
|
||||||
center.setBottom(new VBox(properties, bindings));
|
center.setBottom(new VBox(properties, bindings));
|
||||||
output.setEditable(false);
|
output.setEditable(false);
|
||||||
@@ -448,6 +469,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
draft = value;
|
draft = value;
|
||||||
|
bindingEditor.load(value.text(), value.writable());
|
||||||
submittedSource = null;
|
submittedSource = null;
|
||||||
commitSource();
|
commitSource();
|
||||||
return;
|
return;
|
||||||
@@ -469,6 +491,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
|||||||
var hit = kit.editor().getSelection().getHitItem();
|
var hit = kit.editor().getSelection().getHitItem();
|
||||||
String selectedId = hit == null ? null : hit.getFxId();
|
String selectedId = hit == null ? null : hit.getFxId();
|
||||||
draft = value;
|
draft = value;
|
||||||
|
bindingEditor.load(value.text(), value.writable());
|
||||||
if (!source.getText().equals(value.text())) {
|
if (!source.getText().equals(value.text())) {
|
||||||
int anchor = source.getAnchor(), caret = source.getCaretPosition();
|
int anchor = source.getAnchor(), caret = source.getCaretPosition();
|
||||||
var edits =
|
var edits =
|
||||||
@@ -620,6 +643,45 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
|||||||
bindingBrowser.invalidate(message);
|
bindingBrowser.invalidate(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void editBinding(DirectBindingEditor.Intent intent) {
|
||||||
|
withDraft(
|
||||||
|
() -> {
|
||||||
|
try {
|
||||||
|
if (!draft.writable()) throw new IllegalStateException("Document is read-only");
|
||||||
|
if (!draft.text().equals(intent.source()))
|
||||||
|
throw new IllegalStateException("Document changed; select the binding again");
|
||||||
|
var bindings = new DirectBindingDocument(draft.text());
|
||||||
|
var edits =
|
||||||
|
intent.remove()
|
||||||
|
? bindings.remove(java.util.Objects.requireNonNull(intent.index()))
|
||||||
|
: bindings.write(intent.index(), intent.values(), intent.replace());
|
||||||
|
submit(
|
||||||
|
edits,
|
||||||
|
intent.remove()
|
||||||
|
? "Remove DirectBinding"
|
||||||
|
: intent.index() == null ? "Create DirectBinding" : "Edit DirectBinding");
|
||||||
|
} catch (Exception error) {
|
||||||
|
bindingEditor.error(error.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean bindingPreviewAllowed() {
|
||||||
|
try {
|
||||||
|
var errors = new DirectBindingDocument(draft.text()).errors();
|
||||||
|
if (errors.isEmpty()) return true;
|
||||||
|
status.setText("Preview blocked: " + String.join("; ", errors));
|
||||||
|
if (projection != null)
|
||||||
|
for (String id : projection.components().keySet()) {
|
||||||
|
var region = proxy(id);
|
||||||
|
if (region != null) region.show(images.get(id), "Binding errors; preview outdated");
|
||||||
|
}
|
||||||
|
} catch (Exception error) {
|
||||||
|
report(error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private void discoverBindings(String path, String direction) {
|
private void discoverBindings(String path, String direction) {
|
||||||
withDraft(
|
withDraft(
|
||||||
() -> {
|
() -> {
|
||||||
@@ -740,6 +802,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
|
|||||||
public void render() {
|
public void render() {
|
||||||
withDraft(
|
withDraft(
|
||||||
() -> {
|
() -> {
|
||||||
|
if (!bindingPreviewAllowed()) return;
|
||||||
var selected = examples.getValue();
|
var selected = examples.getValue();
|
||||||
if (selected == null || projection == null) return;
|
if (selected == null || projection == null) return;
|
||||||
long ticket = renderSequence.incrementAndGet();
|
long ticket = renderSequence.incrementAndGet();
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package de.neitzel.fxdesigner.kit;
|
||||||
|
|
||||||
|
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
|
||||||
|
import java.util.*;
|
||||||
|
import org.w3c.dom.*;
|
||||||
|
|
||||||
|
/** Direct binding edit intents translated into lexical ranges in one immutable source revision. */
|
||||||
|
final class DirectBindingDocument {
|
||||||
|
static final String FX = "http://javafx.com/fxml/1", PACKAGE = "de.neitzel.fx.view.";
|
||||||
|
|
||||||
|
record Values(String control, String property, String path, String direction, String converter) {}
|
||||||
|
|
||||||
|
record Entry(int index, Values values, boolean editable) {
|
||||||
|
public String toString() {
|
||||||
|
return values.control() + "." + values.property() + " ← " + values.path();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Conflict extends IllegalArgumentException {
|
||||||
|
Conflict(String target) {
|
||||||
|
super(
|
||||||
|
"Bindings already target "
|
||||||
|
+ target
|
||||||
|
+ ". Select Replace conflicting bindings to replace them in this action.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
final Document document;
|
||||||
|
final XmlSource source;
|
||||||
|
final List<Element> elements = new ArrayList<>();
|
||||||
|
final List<Entry> entries = new ArrayList<>();
|
||||||
|
private final List<String> imports = new ArrayList<>();
|
||||||
|
private final Map<Element, Integer> indexes = new IdentityHashMap<>();
|
||||||
|
|
||||||
|
DirectBindingDocument(String text) throws Exception {
|
||||||
|
this.text = text;
|
||||||
|
document = FxmlProjection.parse(text);
|
||||||
|
source = new XmlSource(text);
|
||||||
|
for (Node n = document.getFirstChild(); n != null; n = n.getNextSibling())
|
||||||
|
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import"))
|
||||||
|
imports.add(pi.getData().trim());
|
||||||
|
var nodes = document.getElementsByTagName("*");
|
||||||
|
for (int i = 0; i < nodes.getLength(); i++) {
|
||||||
|
var e = (Element) nodes.item(i);
|
||||||
|
elements.add(e);
|
||||||
|
indexes.put(e, i);
|
||||||
|
}
|
||||||
|
if (elements.size() != source.tags.size())
|
||||||
|
throw new IllegalArgumentException("XML source ranges do not match parsed elements");
|
||||||
|
for (var e : elements)
|
||||||
|
if (binding(e, "DirectBinding"))
|
||||||
|
entries.add(new Entry(indexes.get(e), values(e), !hasElementChildren(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean named(Element e, String type) {
|
||||||
|
if (e.getTagName().equals(PACKAGE + type)) return true;
|
||||||
|
if (!e.getTagName().equals(type)) return false;
|
||||||
|
boolean own = imports.contains(PACKAGE + type) || imports.contains(PACKAGE + "*");
|
||||||
|
boolean other =
|
||||||
|
imports.stream().anyMatch(i -> i.endsWith("." + type) && !i.equals(PACKAGE + type));
|
||||||
|
return own && !other;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean list(Element e) {
|
||||||
|
return named(e, "BindingList")
|
||||||
|
&& e.getParentNode() instanceof Element p
|
||||||
|
&& FX.equals(p.getNamespaceURI())
|
||||||
|
&& p.getLocalName().equals("define")
|
||||||
|
&& p.getParentNode() == document.getDocumentElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Element bindingList(Element e) {
|
||||||
|
Node parent = e.getParentNode();
|
||||||
|
if (parent instanceof Element p && p.getTagName().equals("elements"))
|
||||||
|
parent = p.getParentNode();
|
||||||
|
return parent instanceof Element p && list(p) ? p : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean binding(Element e, String name) {
|
||||||
|
return named(e, name) && bindingList(e) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Values values(Element e) {
|
||||||
|
return new Values(
|
||||||
|
e.getAttribute("control"),
|
||||||
|
e.getAttribute("property"),
|
||||||
|
e.getAttribute("path"),
|
||||||
|
e.getAttribute("direction"),
|
||||||
|
e.getAttribute("converter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasElementChildren(Element e) {
|
||||||
|
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
|
||||||
|
if (n instanceof Element) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Element entry(int index) {
|
||||||
|
return entries.stream()
|
||||||
|
.filter(e -> e.index() == index)
|
||||||
|
.findFirst()
|
||||||
|
.map(e -> elements.get(index))
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Binding selection is no longer valid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> errors() {
|
||||||
|
var errors = new ArrayList<String>();
|
||||||
|
var ids = new HashMap<String, Integer>();
|
||||||
|
for (var e : elements) {
|
||||||
|
String id = e.getAttributeNS(FX, "id");
|
||||||
|
if (!id.isEmpty()) ids.merge(id, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
var occupied = new HashSet<String>();
|
||||||
|
for (var e : elements)
|
||||||
|
if (binding(e, "DirectBinding") || binding(e, "ComputedBinding")) {
|
||||||
|
String target = e.getAttribute("control") + "." + e.getAttribute("property");
|
||||||
|
if (!occupied.add(target)) errors.add("Duplicate binding target: " + target);
|
||||||
|
if (!hasElementChildren(e)) {
|
||||||
|
if (ids.getOrDefault(e.getAttribute("control"), 0) != 1)
|
||||||
|
errors.add("Missing or ambiguous control: " + e.getAttribute("control"));
|
||||||
|
if (e.getAttribute("property").isBlank()) errors.add("Missing property: " + target);
|
||||||
|
if (binding(e, "DirectBinding")) {
|
||||||
|
if (!e.getAttribute("path")
|
||||||
|
.matches(
|
||||||
|
"[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*(\\.[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*)*"))
|
||||||
|
errors.add("Invalid or missing Model path: " + target);
|
||||||
|
if (!Set.of("view", "model", "both").contains(e.getAttribute("direction")))
|
||||||
|
errors.add("Invalid or missing direction: " + target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Replacement> remove(int index) {
|
||||||
|
Element e = entry(index);
|
||||||
|
var t = source.tags.get(indexes.get(e));
|
||||||
|
return List.of(new Replacement(t.start(), t.end(), ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Replacement> write(Integer index, Values value, boolean replaceConflicts) {
|
||||||
|
if (value.control().isBlank() || value.property().isBlank())
|
||||||
|
throw new IllegalArgumentException("Choose a control and property");
|
||||||
|
Element original = index == null ? null : entry(index);
|
||||||
|
if (original != null && hasElementChildren(original))
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"This declaration uses nested property elements; edit it in Source.");
|
||||||
|
var conflicts =
|
||||||
|
elements.stream()
|
||||||
|
.filter(
|
||||||
|
e ->
|
||||||
|
e != original
|
||||||
|
&& (binding(e, "DirectBinding") || binding(e, "ComputedBinding"))
|
||||||
|
&& e.getAttribute("control").equals(value.control())
|
||||||
|
&& e.getAttribute("property").equals(value.property()))
|
||||||
|
.toList();
|
||||||
|
if (!conflicts.isEmpty() && !replaceConflicts)
|
||||||
|
throw new Conflict(value.control() + "." + value.property());
|
||||||
|
var edits = new ArrayList<Replacement>();
|
||||||
|
for (var e : conflicts) {
|
||||||
|
var t = source.tags.get(indexes.get(e));
|
||||||
|
edits.add(new Replacement(t.start(), t.end(), ""));
|
||||||
|
}
|
||||||
|
if (original != null) {
|
||||||
|
var changed = new LinkedHashMap<String, String>();
|
||||||
|
Map<String, String> fields =
|
||||||
|
Map.of(
|
||||||
|
"control",
|
||||||
|
value.control(),
|
||||||
|
"property",
|
||||||
|
value.property(),
|
||||||
|
"path",
|
||||||
|
value.path(),
|
||||||
|
"direction",
|
||||||
|
value.direction(),
|
||||||
|
"converter",
|
||||||
|
value.converter());
|
||||||
|
for (var f : fields.entrySet())
|
||||||
|
if (!original.getAttribute(f.getKey()).equals(f.getValue()))
|
||||||
|
changed.put(
|
||||||
|
f.getKey(),
|
||||||
|
f.getValue().isEmpty() && f.getKey().equals("converter") ? null : f.getValue());
|
||||||
|
edits.addAll(source.attributes(index, changed));
|
||||||
|
} else {
|
||||||
|
String node =
|
||||||
|
"<"
|
||||||
|
+ PACKAGE
|
||||||
|
+ "DirectBinding control=\""
|
||||||
|
+ XmlSource.escape(value.control(), '"')
|
||||||
|
+ "\" property=\""
|
||||||
|
+ XmlSource.escape(value.property(), '"')
|
||||||
|
+ "\" path=\""
|
||||||
|
+ XmlSource.escape(value.path(), '"')
|
||||||
|
+ "\" direction=\""
|
||||||
|
+ XmlSource.escape(value.direction(), '"')
|
||||||
|
+ "\""
|
||||||
|
+ (value.converter().isEmpty()
|
||||||
|
? ""
|
||||||
|
: " converter=\"" + XmlSource.escape(value.converter(), '"') + "\"")
|
||||||
|
+ "/>";
|
||||||
|
var lists = elements.stream().filter(this::list).toList();
|
||||||
|
if (!lists.isEmpty()) {
|
||||||
|
Element container = lists.getFirst();
|
||||||
|
for (Node n = container.getFirstChild(); n != null; n = n.getNextSibling())
|
||||||
|
if (n instanceof Element e && e.getTagName().equals("elements")) {
|
||||||
|
container = e;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
edits.add(append(container, node, ""));
|
||||||
|
} else {
|
||||||
|
String body =
|
||||||
|
"<"
|
||||||
|
+ PACKAGE
|
||||||
|
+ "BindingList>"
|
||||||
|
+ newline()
|
||||||
|
+ " "
|
||||||
|
+ node
|
||||||
|
+ newline()
|
||||||
|
+ "</"
|
||||||
|
+ PACKAGE
|
||||||
|
+ "BindingList>";
|
||||||
|
Element root = document.getDocumentElement();
|
||||||
|
Element define =
|
||||||
|
elements.stream()
|
||||||
|
.filter(
|
||||||
|
e ->
|
||||||
|
FX.equals(e.getNamespaceURI())
|
||||||
|
&& e.getLocalName().equals("define")
|
||||||
|
&& e.getParentNode() == root)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (define != null) edits.add(append(define, body, ""));
|
||||||
|
else {
|
||||||
|
String prefix = root.lookupPrefix(FX), attribute = "";
|
||||||
|
if (prefix == null) {
|
||||||
|
prefix = "fx";
|
||||||
|
int suffix = 1;
|
||||||
|
while (root.lookupNamespaceURI(prefix) != null) prefix = "fx" + (suffix++);
|
||||||
|
attribute = " xmlns:" + prefix + "=\"" + FX + "\"";
|
||||||
|
}
|
||||||
|
body =
|
||||||
|
"<" + prefix + ":define>" + newline() + body + newline() + "</" + prefix + ":define>";
|
||||||
|
var tag = source.tags.get(indexes.get(root));
|
||||||
|
if (!attribute.isEmpty() && !tag.selfClosing())
|
||||||
|
edits.add(new Replacement(tag.insert(), tag.insert(), attribute));
|
||||||
|
edits.add(append(root, body, tag.selfClosing() ? attribute : ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(edits);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String newline() {
|
||||||
|
return text.contains("\r\n") ? "\r\n" : "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Replacement append(Element container, String child, String attributes) {
|
||||||
|
var t = source.tags.get(indexes.get(container));
|
||||||
|
String eol = newline();
|
||||||
|
if (t.selfClosing())
|
||||||
|
return new Replacement(
|
||||||
|
t.insert(),
|
||||||
|
t.openEnd(),
|
||||||
|
attributes + ">" + eol + child + eol + "</" + container.getTagName() + ">");
|
||||||
|
return new Replacement(t.closeStart(), t.closeStart(), eol + child + eol);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package de.neitzel.fxdesigner.kit;
|
||||||
|
|
||||||
|
import java.util.function.*;
|
||||||
|
import javafx.scene.control.*;
|
||||||
|
import javafx.scene.layout.*;
|
||||||
|
|
||||||
|
/** View-wide declarations and an explicit, revision-bound edit form shared by both hosts. */
|
||||||
|
final class DirectBindingEditor extends VBox {
|
||||||
|
record Intent(
|
||||||
|
String source,
|
||||||
|
Integer index,
|
||||||
|
DirectBindingDocument.Values values,
|
||||||
|
boolean remove,
|
||||||
|
boolean replace) {}
|
||||||
|
|
||||||
|
private final ListView<DirectBindingDocument.Entry> bindings = new ListView<>();
|
||||||
|
private final TextField control = field("control"),
|
||||||
|
property = field("property"),
|
||||||
|
path = field("path"),
|
||||||
|
converter = field("converter");
|
||||||
|
private final ComboBox<String> direction = new ComboBox<>();
|
||||||
|
private final CheckBox replace = new CheckBox("Replace conflicting bindings");
|
||||||
|
private final Label status = new Label("Open a valid FXML document to edit bindings.");
|
||||||
|
private final Button fresh = new Button("New binding"),
|
||||||
|
apply = new Button("Apply binding"),
|
||||||
|
remove = new Button("Remove binding");
|
||||||
|
private String source = "";
|
||||||
|
private Integer selected;
|
||||||
|
private boolean loading, writable, active, followingCanvas;
|
||||||
|
private final Consumer<String> navigate;
|
||||||
|
|
||||||
|
DirectBindingEditor(Consumer<Intent> edit, Consumer<String> navigate) {
|
||||||
|
this.navigate = navigate;
|
||||||
|
setSpacing(5);
|
||||||
|
bindings.setId("direct-bindings");
|
||||||
|
bindings.setPrefHeight(100);
|
||||||
|
direction.setId("direct-direction");
|
||||||
|
direction.getItems().setAll("view", "model", "both");
|
||||||
|
direction.setValue("both");
|
||||||
|
replace.setId("direct-replace");
|
||||||
|
status.setId("direct-status");
|
||||||
|
status.setWrapText(true);
|
||||||
|
fresh.setOnAction(e -> newBinding(new DirectBindingDocument.Values("", "", "", "both", "")));
|
||||||
|
apply.setOnAction(
|
||||||
|
e -> edit.accept(new Intent(source, selected, values(), false, replace.isSelected())));
|
||||||
|
remove.setOnAction(e -> edit.accept(new Intent(source, selected, values(), true, false)));
|
||||||
|
bindings
|
||||||
|
.getSelectionModel()
|
||||||
|
.selectedItemProperty()
|
||||||
|
.addListener(
|
||||||
|
(o, a, b) -> {
|
||||||
|
if (loading || b == null) return;
|
||||||
|
selected = b.index();
|
||||||
|
active = b.editable();
|
||||||
|
fill(b.values());
|
||||||
|
replace.setSelected(false);
|
||||||
|
buttons();
|
||||||
|
if (!followingCanvas) navigate.accept(b.values().control());
|
||||||
|
if (!b.editable())
|
||||||
|
status.setText(
|
||||||
|
"Nested property elements are preserved. Edit this declaration in Source, or"
|
||||||
|
+ " remove it explicitly.");
|
||||||
|
});
|
||||||
|
var grid = new GridPane();
|
||||||
|
grid.setHgap(8);
|
||||||
|
grid.setVgap(4);
|
||||||
|
grid.addRow(
|
||||||
|
0,
|
||||||
|
new Label("Control (fx:id)"),
|
||||||
|
control,
|
||||||
|
new Label("Property"),
|
||||||
|
property,
|
||||||
|
new Label("Direction"),
|
||||||
|
direction);
|
||||||
|
grid.addRow(1, new Label("Model path"), path, new Label("Converter (optional)"), converter);
|
||||||
|
getChildren().addAll(bindings, grid, new HBox(8, fresh, apply, remove, replace), status);
|
||||||
|
buttons();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TextField field(String name) {
|
||||||
|
var f = new TextField();
|
||||||
|
f.setId("direct-" + name);
|
||||||
|
f.setPrefColumnCount(14);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DirectBindingDocument.Values values() {
|
||||||
|
return new DirectBindingDocument.Values(
|
||||||
|
control.getText().trim(),
|
||||||
|
property.getText().trim(),
|
||||||
|
path.getText().trim(),
|
||||||
|
direction.getValue(),
|
||||||
|
converter.getText().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fill(DirectBindingDocument.Values v) {
|
||||||
|
control.setText(v.control());
|
||||||
|
property.setText(v.property());
|
||||||
|
path.setText(v.path());
|
||||||
|
direction.setValue(v.direction());
|
||||||
|
converter.setText(v.converter());
|
||||||
|
}
|
||||||
|
|
||||||
|
void newBinding(DirectBindingDocument.Values v) {
|
||||||
|
loading = true;
|
||||||
|
bindings.getSelectionModel().clearSelection();
|
||||||
|
loading = false;
|
||||||
|
selected = null;
|
||||||
|
active = true;
|
||||||
|
fill(v);
|
||||||
|
replace.setSelected(false);
|
||||||
|
buttons();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buttons() {
|
||||||
|
fresh.setDisable(!writable);
|
||||||
|
apply.setDisable(!writable || !active);
|
||||||
|
remove.setDisable(!writable || selected == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void load(String text, boolean editable) {
|
||||||
|
if (source.equals(text) && writable == editable) return;
|
||||||
|
source = text;
|
||||||
|
writable = editable;
|
||||||
|
loading = true;
|
||||||
|
selected = null;
|
||||||
|
active = false;
|
||||||
|
try {
|
||||||
|
var doc = new DirectBindingDocument(text);
|
||||||
|
bindings.getItems().setAll(doc.entries);
|
||||||
|
bindings.getSelectionModel().clearSelection();
|
||||||
|
status.setText(
|
||||||
|
doc.errors().isEmpty()
|
||||||
|
? "Select a DirectBinding or choose New binding. Converter types are not yet"
|
||||||
|
+ " verified."
|
||||||
|
: String.join("\n", doc.errors()));
|
||||||
|
} catch (Exception error) {
|
||||||
|
bindings.getItems().clear();
|
||||||
|
writable = false;
|
||||||
|
status.setText("Binding editing unavailable: " + error.getMessage());
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
buttons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void selectControl(String id) {
|
||||||
|
if (loading || id == null) return;
|
||||||
|
var selected = bindings.getSelectionModel().getSelectedItem();
|
||||||
|
if (selected != null && selected.values().control().equals(id)) return;
|
||||||
|
bindings.getItems().stream()
|
||||||
|
.filter(e -> e.values().control().equals(id))
|
||||||
|
.findFirst()
|
||||||
|
.ifPresent(
|
||||||
|
e -> {
|
||||||
|
followingCanvas = true;
|
||||||
|
try {
|
||||||
|
bindings.getSelectionModel().select(e);
|
||||||
|
} finally {
|
||||||
|
followingCanvas = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void error(String message) {
|
||||||
|
status.setText(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,14 @@ import java.util.regex.*;
|
|||||||
final class XmlSource {
|
final class XmlSource {
|
||||||
record Attribute(int start, int end, int valueStart, int valueEnd, char quote) {}
|
record Attribute(int start, int end, int valueStart, int valueEnd, char quote) {}
|
||||||
|
|
||||||
record Tag(int insert, Map<String, Attribute> attributes) {}
|
record Tag(
|
||||||
|
int start,
|
||||||
|
int insert,
|
||||||
|
int openEnd,
|
||||||
|
int closeStart,
|
||||||
|
int end,
|
||||||
|
boolean selfClosing,
|
||||||
|
Map<String, Attribute> attributes) {}
|
||||||
|
|
||||||
private static final Pattern ATTRIBUTE =
|
private static final Pattern ATTRIBUTE =
|
||||||
Pattern.compile("([^\\s=<>/]+)\\s*=\\s*(['\"])(.*?)\\2", Pattern.DOTALL);
|
Pattern.compile("([^\\s=<>/]+)\\s*=\\s*(['\"])(.*?)\\2", Pattern.DOTALL);
|
||||||
@@ -19,6 +26,7 @@ final class XmlSource {
|
|||||||
XmlSource(String text) throws IOException {
|
XmlSource(String text) throws IOException {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
var stack = new ArrayDeque<Integer>();
|
||||||
while ((i = text.indexOf('<', i)) >= 0) {
|
while ((i = text.indexOf('<', i)) >= 0) {
|
||||||
String end =
|
String end =
|
||||||
text.startsWith("<!--", i)
|
text.startsWith("<!--", i)
|
||||||
@@ -53,10 +61,24 @@ final class XmlSource {
|
|||||||
matcher.start(3),
|
matcher.start(3),
|
||||||
matcher.end(3),
|
matcher.end(3),
|
||||||
matcher.group(2).charAt(0)));
|
matcher.group(2).charAt(0)));
|
||||||
tags.add(new Tag(text.charAt(i - 1) == '/' ? i - 1 : i, attrs));
|
boolean self = text.charAt(i - 1) == '/';
|
||||||
|
int index = tags.size();
|
||||||
|
tags.add(
|
||||||
|
new Tag(
|
||||||
|
start, self ? i - 1 : i, i + 1, self ? i - 1 : -1, self ? i + 1 : -1, self, attrs));
|
||||||
|
if (!self) stack.push(index);
|
||||||
|
} else {
|
||||||
|
if (stack.isEmpty()) throw new IOException("Unbalanced XML closing tag");
|
||||||
|
int index = stack.pop();
|
||||||
|
var tag = tags.get(index);
|
||||||
|
tags.set(
|
||||||
|
index,
|
||||||
|
new Tag(
|
||||||
|
tag.start(), tag.insert(), tag.openEnd(), start, i + 1, false, tag.attributes()));
|
||||||
}
|
}
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
if (!stack.isEmpty()) throw new IOException("Unclosed XML element");
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Replacement> attributes(int index, Map<String, String> values) {
|
List<Replacement> attributes(int index, Map<String, String> values) {
|
||||||
|
|||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package de.neitzel.fxdesigner.kit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import de.neitzel.fxdesigner.contracts.*;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.*;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class DirectBindingDocumentTest {
|
||||||
|
private static final String P = "de.neitzel.fx.view.";
|
||||||
|
private static final DirectBindingDocument.Values VALUE =
|
||||||
|
new DirectBindingDocument.Values("label", "text", "name", "view", "");
|
||||||
|
|
||||||
|
private String apply(String text, List<EditBatch.Replacement> edits) throws Exception {
|
||||||
|
String result =
|
||||||
|
new EditBatch(7, "binding", edits)
|
||||||
|
.applyTo(new DocumentSnapshot(URI.create("file:///test.fxml"), 7, text, true, false));
|
||||||
|
new DirectBindingDocument(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsRequiredContainersWithoutSerializingTheDocument() throws Exception {
|
||||||
|
for (String root :
|
||||||
|
List.of(
|
||||||
|
"<VBox/>",
|
||||||
|
"<VBox xmlns:fx='urn:other'/>",
|
||||||
|
"<VBox><!-- untouched -->\r\n</VBox>",
|
||||||
|
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define/></VBox>",
|
||||||
|
"<VBox xmlns:f='http://javafx.com/fxml/1'><f:define><"
|
||||||
|
+ P
|
||||||
|
+ "BindingList/></f:define></VBox>")) {
|
||||||
|
String text = "<?neitzel-model app.Person?>\n" + root;
|
||||||
|
var doc = new DirectBindingDocument(text);
|
||||||
|
String result = apply(text, doc.write(null, VALUE, false));
|
||||||
|
var created = new DirectBindingDocument(result);
|
||||||
|
assertEquals(1, created.entries.size());
|
||||||
|
assertEquals(VALUE, created.entries.getFirst().values());
|
||||||
|
assertTrue(result.startsWith("<?neitzel-model app.Person?>\n"));
|
||||||
|
assertFalse(result.contains("<?import"));
|
||||||
|
if (text.contains("untouched")) assertTrue(result.contains("<!-- untouched -->\r\n"));
|
||||||
|
if (text.contains("urn:other"))
|
||||||
|
assertTrue(result.contains("xmlns:fx1=\"http://javafx.com/fxml/1\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String existing() {
|
||||||
|
return "<?neitzel-model app.Person?>\n"
|
||||||
|
+ "<?import de.neitzel.fx.view.*?>\n"
|
||||||
|
+ "<VBox xmlns:fx='http://javafx.com/fxml/1'><!-- before"
|
||||||
|
+ " --><fx:define><BindingList><!-- preserve --><DirectBinding control = 'label'"
|
||||||
|
+ " property='text' path = 'name' direction='view' converter='app.Old'"
|
||||||
|
+ " custom='untouched'/><!-- after --><app.Unknown"
|
||||||
|
+ " value='keep'/></BindingList></fx:define><Label fx:id='label'/></VBox>";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void changesOnlyRequestedAttributeRangesAndRetainsUnknownContent() throws Exception {
|
||||||
|
String text = existing();
|
||||||
|
var doc = new DirectBindingDocument(text);
|
||||||
|
int index = doc.entries.getFirst().index();
|
||||||
|
var value = new DirectBindingDocument.Values("label", "text", "home.street", "both", "app.New");
|
||||||
|
String updated = apply(text, doc.write(index, value, false));
|
||||||
|
assertEquals(
|
||||||
|
text.replace("path = 'name'", "path = 'home.street'")
|
||||||
|
.replace("direction='view'", "direction='both'")
|
||||||
|
.replace("converter='app.Old'", "converter='app.New'"),
|
||||||
|
updated);
|
||||||
|
assertTrue(new DirectBindingDocument(updated).write(index, value, false).isEmpty());
|
||||||
|
String escaped =
|
||||||
|
apply(
|
||||||
|
text,
|
||||||
|
doc.write(
|
||||||
|
index,
|
||||||
|
new DirectBindingDocument.Values("label", "text", "a'&<", "view", ""),
|
||||||
|
false));
|
||||||
|
assertEquals("a'&<", new DirectBindingDocument(escaped).entries.getFirst().values().path());
|
||||||
|
assertFalse(escaped.contains("converter='app.Old'"));
|
||||||
|
assertFalse(new DirectBindingDocument(escaped).errors().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removesOnlyTheSelectedElementIncludingPairedTags() throws Exception {
|
||||||
|
String text =
|
||||||
|
existing()
|
||||||
|
.replace("custom='untouched'/>", "custom='untouched'><!-- inner --></DirectBinding>");
|
||||||
|
var doc = new DirectBindingDocument(text);
|
||||||
|
var tag = doc.source.tags.get(doc.entries.getFirst().index());
|
||||||
|
String result = apply(text, doc.remove(doc.entries.getFirst().index()));
|
||||||
|
assertEquals(text.substring(0, tag.start()) + text.substring(tag.end()), result);
|
||||||
|
assertTrue(result.contains("<!-- preserve --><!-- after -->"));
|
||||||
|
assertTrue(result.contains("app.Unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replacesAllConflictingDirectAndComputedBindingsAtomically() throws Exception {
|
||||||
|
String text =
|
||||||
|
existing()
|
||||||
|
.replace(
|
||||||
|
"<!-- after -->",
|
||||||
|
"<ComputedBinding control='label' property='text' method='#caption'/><!-- after"
|
||||||
|
+ " -->");
|
||||||
|
var doc = new DirectBindingDocument(text);
|
||||||
|
assertTrue(doc.errors().stream().anyMatch(s -> s.contains("Duplicate")));
|
||||||
|
assertThrows(DirectBindingDocument.Conflict.class, () -> doc.write(null, VALUE, false));
|
||||||
|
String replaced = apply(text, doc.write(null, VALUE, true));
|
||||||
|
var parsed = new DirectBindingDocument(replaced);
|
||||||
|
assertEquals(1, parsed.entries.size());
|
||||||
|
assertFalse(replaced.contains("<ComputedBinding"));
|
||||||
|
assertTrue(parsed.errors().isEmpty());
|
||||||
|
assertTrue(replaced.contains("app.Unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void supportsExplicitElementsWrapperAndRefusesNestedPropertyEdits() throws Exception {
|
||||||
|
String text =
|
||||||
|
"<?import de.neitzel.fx.view.*?><VBox"
|
||||||
|
+ " xmlns:fx='http://javafx.com/fxml/1'><fx:define><BindingList><elements><DirectBinding"
|
||||||
|
+ " control='label'><property>text</property></DirectBinding></elements></BindingList></fx:define></VBox>";
|
||||||
|
var doc = new DirectBindingDocument(text);
|
||||||
|
assertFalse(doc.entries.getFirst().editable());
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> doc.write(doc.entries.getFirst().index(), VALUE, false));
|
||||||
|
String result =
|
||||||
|
apply(
|
||||||
|
text,
|
||||||
|
doc.write(
|
||||||
|
null,
|
||||||
|
new DirectBindingDocument.Values("other", "text", "name", "view", ""),
|
||||||
|
false));
|
||||||
|
assertEquals(2, new DirectBindingDocument(result).entries.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void usesOnlyTheLibraryDeclarationsAndRejectsMalformedXml() throws Exception {
|
||||||
|
String text =
|
||||||
|
existing().replace("<?import de.neitzel.fx.view.*?>", "<?import app.DirectBinding?>");
|
||||||
|
assertTrue(new DirectBindingDocument(text).entries.isEmpty());
|
||||||
|
assertThrows(Exception.class, () -> new DirectBindingDocument("<VBox>"));
|
||||||
|
assertThrows(
|
||||||
|
Exception.class,
|
||||||
|
() -> new DirectBindingDocument("<!DOCTYPE x SYSTEM 'file:///etc/passwd'><x/>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -207,6 +207,199 @@ public final class ProjectionHostChecks {
|
|||||||
await(() -> fx(() -> source(pane).getText().equals(before)));
|
await(() -> fx(() -> source(pane).getText().equals(before)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void bindingTab(DesignerPane pane, String name) throws Exception {
|
||||||
|
fx(
|
||||||
|
() -> {
|
||||||
|
pane.lookupAll(".tab-pane").stream()
|
||||||
|
.filter(n -> n instanceof TabPane)
|
||||||
|
.map(n -> (TabPane) n)
|
||||||
|
.forEach(
|
||||||
|
t ->
|
||||||
|
t.getTabs().stream()
|
||||||
|
.filter(tab -> tab.getText().equals(name))
|
||||||
|
.findFirst()
|
||||||
|
.ifPresent(tab -> t.getSelectionModel().select(tab)));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void directField(DesignerPane pane, String field, String value) throws Exception {
|
||||||
|
fx(
|
||||||
|
() -> {
|
||||||
|
((TextField) pane.lookup("#direct-" + field)).setText(value);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void selectDirect(DesignerPane pane, String control) throws Exception {
|
||||||
|
fx(
|
||||||
|
() -> {
|
||||||
|
var list = (ListView<Object>) pane.lookup("#direct-bindings");
|
||||||
|
list.getSelectionModel()
|
||||||
|
.select(
|
||||||
|
list.getItems().stream()
|
||||||
|
.filter(e -> e.toString().startsWith(control + "."))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The live IDE probe uses a separate plugin loader: assert exported UI and XML, not internals.
|
||||||
|
private static List<org.w3c.dom.Element> directDeclarations(String text) throws Exception {
|
||||||
|
var factory = javax.xml.parsers.DocumentBuilderFactory.newInstance();
|
||||||
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
|
var parsed =
|
||||||
|
factory
|
||||||
|
.newDocumentBuilder()
|
||||||
|
.parse(new org.xml.sax.InputSource(new java.io.StringReader(text)));
|
||||||
|
var nodes = parsed.getElementsByTagName("*");
|
||||||
|
var result = new ArrayList<org.w3c.dom.Element>();
|
||||||
|
for (int i = 0; i < nodes.getLength(); i++) {
|
||||||
|
var element = (org.w3c.dom.Element) nodes.item(i);
|
||||||
|
if (Set.of("DirectBinding", "de.neitzel.fx.view.DirectBinding")
|
||||||
|
.contains(element.getTagName())) result.add(element);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String documentText(DocumentSession doc) throws Exception {
|
||||||
|
return doc.snapshot().toCompletableFuture().get().text();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void directBindingEdits(DesignerPane pane, DocumentSession document, String full)
|
||||||
|
throws Exception {
|
||||||
|
bindingTab(pane, "Direct bindings");
|
||||||
|
click(pane, "New binding");
|
||||||
|
directField(pane, "control", "action");
|
||||||
|
directField(pane, "property", "text");
|
||||||
|
directField(pane, "path", "name");
|
||||||
|
fx(
|
||||||
|
() -> {
|
||||||
|
((ComboBox<String>) pane.lookup("#direct-direction")).setValue("view");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
click(pane, "Apply binding");
|
||||||
|
await(() -> !documentText(document).equals(full));
|
||||||
|
String created = documentText(document);
|
||||||
|
check(
|
||||||
|
directDeclarations(created).stream()
|
||||||
|
.anyMatch(
|
||||||
|
e ->
|
||||||
|
e.getAttribute("control").equals("action")
|
||||||
|
&& e.getAttribute("path").equals("name")),
|
||||||
|
"Create binding failed");
|
||||||
|
click(pane, "Undo");
|
||||||
|
await(() -> documentText(document).equals(full));
|
||||||
|
click(pane, "Redo");
|
||||||
|
await(() -> documentText(document).equals(created));
|
||||||
|
await(() -> fx(() -> source(pane).getText().equals(created)));
|
||||||
|
selectDirect(pane, "action");
|
||||||
|
directField(pane, "path", "home.street");
|
||||||
|
click(pane, "Apply binding");
|
||||||
|
String changed =
|
||||||
|
created.replace(
|
||||||
|
"path=\"name\" direction=\"view\"", "path=\"home.street\" direction=\"view\"");
|
||||||
|
await(() -> documentText(document).equals(changed));
|
||||||
|
click(pane, "Undo");
|
||||||
|
await(() -> documentText(document).equals(created));
|
||||||
|
click(pane, "Redo");
|
||||||
|
await(() -> documentText(document).equals(changed));
|
||||||
|
await(() -> fx(() -> source(pane).getText().equals(changed)));
|
||||||
|
document
|
||||||
|
.save(document.snapshot().toCompletableFuture().get().revision())
|
||||||
|
.toCompletableFuture()
|
||||||
|
.get();
|
||||||
|
check(
|
||||||
|
java.nio.file.Files.readString(
|
||||||
|
java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document()))
|
||||||
|
.equals(changed),
|
||||||
|
"Binding save changed source");
|
||||||
|
click(pane, "Render drafts");
|
||||||
|
await(
|
||||||
|
() ->
|
||||||
|
fx(
|
||||||
|
() ->
|
||||||
|
object(pane, "home").getSceneGraphObject() instanceof SnapshotRegion r
|
||||||
|
&& r.image() != null
|
||||||
|
&& r.statusText().equals("Target runtime")));
|
||||||
|
selectDirect(pane, "action");
|
||||||
|
click(pane, "Remove binding");
|
||||||
|
await(() -> !documentText(document).equals(changed));
|
||||||
|
String removed = documentText(document);
|
||||||
|
check(
|
||||||
|
directDeclarations(removed).stream()
|
||||||
|
.noneMatch(e -> e.getAttribute("control").equals("action")),
|
||||||
|
"Remove binding failed");
|
||||||
|
click(pane, "Undo");
|
||||||
|
await(() -> documentText(document).equals(changed));
|
||||||
|
click(pane, "Redo");
|
||||||
|
await(() -> documentText(document).equals(removed));
|
||||||
|
await(() -> fx(() -> source(pane).getText().equals(removed)));
|
||||||
|
// Conflicting creation requires explicit replacement and remains one history entry.
|
||||||
|
click(pane, "New binding");
|
||||||
|
directField(pane, "control", "rankInput");
|
||||||
|
directField(pane, "property", "text");
|
||||||
|
directField(pane, "path", "name");
|
||||||
|
fx(
|
||||||
|
() -> {
|
||||||
|
((ComboBox<String>) pane.lookup("#direct-direction")).setValue("view");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
click(pane, "Apply binding");
|
||||||
|
check(documentText(document).equals(removed), "Conflict silently overwrote a binding");
|
||||||
|
fx(
|
||||||
|
() -> {
|
||||||
|
((CheckBox) pane.lookup("#direct-replace")).setSelected(true);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
click(pane, "Apply binding");
|
||||||
|
await(() -> !documentText(document).equals(removed));
|
||||||
|
String replaced = documentText(document);
|
||||||
|
var rank =
|
||||||
|
directDeclarations(replaced).stream()
|
||||||
|
.filter(e -> e.getAttribute("control").equals("rankInput"))
|
||||||
|
.toList();
|
||||||
|
check(
|
||||||
|
rank.size() == 1 && rank.getFirst().getAttribute("path").equals("name"),
|
||||||
|
"Explicit replace failed");
|
||||||
|
click(pane, "Undo");
|
||||||
|
await(() -> documentText(document).equals(removed));
|
||||||
|
click(pane, "Redo");
|
||||||
|
await(() -> documentText(document).equals(replaced));
|
||||||
|
await(() -> fx(() -> source(pane).getText().equals(replaced)));
|
||||||
|
selectDirect(pane, "rankInput");
|
||||||
|
directField(pane, "path", "invalid path");
|
||||||
|
click(pane, "Apply binding");
|
||||||
|
await(() -> documentText(document).contains("path=\"invalid path\""));
|
||||||
|
String invalid = documentText(document);
|
||||||
|
await(() -> fx(() -> source(pane).getText().equals(invalid)));
|
||||||
|
document
|
||||||
|
.save(document.snapshot().toCompletableFuture().get().revision())
|
||||||
|
.toCompletableFuture()
|
||||||
|
.get();
|
||||||
|
check(
|
||||||
|
java.nio.file.Files.readString(
|
||||||
|
java.nio.file.Path.of(document.snapshot().toCompletableFuture().get().document()))
|
||||||
|
.equals(invalid),
|
||||||
|
"Invalid binding could not be saved");
|
||||||
|
click(pane, "Render drafts");
|
||||||
|
await(
|
||||||
|
() ->
|
||||||
|
fx(
|
||||||
|
() ->
|
||||||
|
pane.lookupAll(".label").stream()
|
||||||
|
.anyMatch(
|
||||||
|
n ->
|
||||||
|
n instanceof Label l
|
||||||
|
&& l.getText() != null
|
||||||
|
&& l.getText().startsWith("Preview blocked:"))));
|
||||||
|
click(pane, "Undo");
|
||||||
|
await(() -> documentText(document).equals(replaced));
|
||||||
|
set(document, full);
|
||||||
|
await(() -> fx(() -> source(pane).getText().equals(full)));
|
||||||
|
}
|
||||||
|
|
||||||
public static void run(DesignerPane pane, DocumentSession document) throws Exception {
|
public static void run(DesignerPane pane, DocumentSession document) throws Exception {
|
||||||
String original = document.snapshot().toCompletableFuture().get().text();
|
String original = document.snapshot().toCompletableFuture().get().text();
|
||||||
// The same real application, now with nested, fully-qualified Component declarations.
|
// The same real application, now with nested, fully-qualified Component declarations.
|
||||||
@@ -429,11 +622,12 @@ public final class ProjectionHostChecks {
|
|||||||
fx(
|
fx(
|
||||||
() -> {
|
() -> {
|
||||||
pane.lookupAll(".titled-pane").stream()
|
pane.lookupAll(".titled-pane").stream()
|
||||||
.filter(n -> n instanceof TitledPane t && t.getText().equals("Bindings — discovery"))
|
.filter(n -> n instanceof TitledPane t && t.getText().equals("Bindings"))
|
||||||
.map(n -> (TitledPane) n)
|
.map(n -> (TitledPane) n)
|
||||||
.forEach(t -> t.setExpanded(true));
|
.forEach(t -> t.setExpanded(true));
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
bindingTab(pane, "Discover targets");
|
||||||
click(pane, "Discover bindings");
|
click(pane, "Discover bindings");
|
||||||
await(
|
await(
|
||||||
() ->
|
() ->
|
||||||
@@ -481,6 +675,29 @@ public final class ProjectionHostChecks {
|
|||||||
check(
|
check(
|
||||||
document.snapshot().toCompletableFuture().get().text().equals(full),
|
document.snapshot().toCompletableFuture().get().text().equals(full),
|
||||||
"Binding discovery altered source");
|
"Binding discovery altered source");
|
||||||
|
fx(
|
||||||
|
() -> {
|
||||||
|
var targets =
|
||||||
|
(TableView<de.neitzel.fxdesigner.protocol.BindingMetadata.Target>)
|
||||||
|
pane.lookup("#binding-targets");
|
||||||
|
targets
|
||||||
|
.getSelectionModel()
|
||||||
|
.select(
|
||||||
|
targets.getItems().stream()
|
||||||
|
.filter(t -> t.control().equals("rankInput") && t.property().equals("text"))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
click(pane, "Use selected target");
|
||||||
|
check(
|
||||||
|
fx(
|
||||||
|
() ->
|
||||||
|
((TextField) pane.lookup("#direct-control")).getText().equals("rankInput")
|
||||||
|
&& ((TextField) pane.lookup("#direct-path")).getText().equals("rank")),
|
||||||
|
"Discovery did not populate binding form");
|
||||||
|
check(documentText(document).equals(full), "Discovery handoff changed source before Apply");
|
||||||
|
directBindingEdits(pane, document, full);
|
||||||
document
|
document
|
||||||
.save(document.snapshot().toCompletableFuture().get().revision())
|
.save(document.snapshot().toCompletableFuture().get().revision())
|
||||||
.toCompletableFuture()
|
.toCompletableFuture()
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Shared Model and binding-target discovery
|
# Shared Model and binding-target discovery
|
||||||
|
|
||||||
This is the first stage-4 implementation slice: a read-only binding browser shared by standalone and IntelliJ. Binding declaration edits, Converter selection, generated metadata and the complete inspector remain subsequent work.
|
This is the first stage-4 implementation slice: a read-only binding browser shared by standalone and IntelliJ. The [DirectBinding inspector](direct-binding-inspector.md) now uses discovered targets to start editable declarations. Converter discovery, generated metadata and the complete inspector remain subsequent work.
|
||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
Build and Refresh a fixture, expand **Bindings — discovery** below the designer, and select **Discover bindings**. Select a Model path and direction (`view`, `model`, `both`); compatible targets appear first, followed by unresolved targets and mismatches with explanations. Discovery leaves the authoritative FXML and undo history untouched. Errors leave source editing available.
|
Build and Refresh a fixture, expand **Bindings**, open **Discover targets**, and select **Discover bindings**. Select a Model path and direction (`view`, `model`, `both`); compatible targets appear first, followed by unresolved targets and mismatches with explanations. Discovery leaves the authoritative FXML and undo history untouched. Errors leave source editing available.
|
||||||
|
|
||||||
The document explicitly declares its Model in `<?neitzel-model fixture.model.Person?>`. A missing declaration prompts for it; an example Model is not silently substituted. Fixtures now declare Person for Person/Full Views and Address for their Component resources.
|
The document explicitly declares its Model in `<?neitzel-model fixture.model.Person?>`. A missing declaration prompts for it; an example Model is not silently substituted. Fixtures now declare Person for Person/Full Views and Address for their Component resources.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Shared DirectBinding inspector
|
||||||
|
|
||||||
|
The first editable stage-4 slice runs in the shared Kit UI in standalone and IntelliJ. Open **Bindings → Direct bindings**, select a declaration or choose **New binding**, then **Apply binding** or **Remove binding**. Fields cover control fx:id, property, Model path, direction and optional Converter class. **Discover targets → Use selected target** prepares a new form from discovery without changing source until Apply. Selecting a declaration selects its target on the canvas; selecting a bound control selects its declaration.
|
||||||
|
|
||||||
|
## Source and history contract
|
||||||
|
|
||||||
|
Each create, change, remove or explicit replacement becomes one revision-checked host document command. Normal host Undo/Redo restores the exact prior/new source. Saving uses the authoritative document. The form refuses stale source and read-only documents; a source change resets its active selection so a previous range cannot be reused accidentally.
|
||||||
|
|
||||||
|
A secure DOM identifies declarations; a lexical range index edits only the relevant source spans. Existing quote styles, imports, Model processing instructions, comments, unrelated whitespace and unfamiliar declarations remain intact. New declarations use fully qualified names, avoiding import changes. Missing root fx:define/BindingList containers and namespace declarations are added as needed, including self-closing containers and alternate FXML prefixes. Explicit BindingList elements wrappers are supported. Removing a binding leaves its surrounding comments and whitespace.
|
||||||
|
|
||||||
|
Existing DirectBindings written with attributes are editable. Declarations using nested property elements remain visible and can be explicitly removed, but must be changed in Source. This does not flatten or guess unsupported syntax. Short class names require the library import; conflicting explicit imports are not treated as library declarations.
|
||||||
|
|
||||||
|
A target occupied by another DirectBinding or ComputedBinding requires **Replace conflicting bindings**. Replacement removes those conflicting declarations and adds/changes the chosen binding in one undo command. ComputedBinding authoring is not included.
|
||||||
|
|
||||||
|
## Validation boundary
|
||||||
|
|
||||||
|
Structural diagnostics cover duplicate targets, missing/ambiguous fx:id, empty property, malformed/missing Model path and invalid direction. These known errors block a new canvas/interactive preview request while allowing save and source repair. Type compatibility remains the separate discovery result; the editor does not yet prove path existence, Converter compatibility or runtime property availability. A syntactically valid but incompatible declaration can therefore still fail in the target runtime.
|
||||||
|
|
||||||
|
Converter names are preserved/editable text, not a discovered or verified Converter chooser. Generated/custom ViewModel metadata, Computed/Repeat/Table authoring, complete semantic diagnostics and identifier rename/copy/subtree-delete reference repair remain later stage-4 work.
|
||||||
|
|
||||||
|
## Automated qualification
|
||||||
|
|
||||||
|
Lexical unit checks cover creation of containers/namespaces, attribute-only changes and escaping, unknown content/comment preservation, paired-tag removal, duplicate replacement, explicit elements wrappers, unsupported nested syntax and secure parsing.
|
||||||
|
|
||||||
|
The same live shared-UI scenario runs in standalone and the registered IntelliJ editor against classpath and module-path fixtures. It creates, changes, removes and explicitly replaces bindings; checks exact source after one Undo/Redo per operation; saves; loads the edited document through the real target runtime; and saves an invalid path while checking that preview is blocked. Existing projection, sizing, discovery, session and shutdown checks remain enabled. No human test is required for this slice. Previously skipped manual checks remain skipped, not retroactively passed.
|
||||||
|
|
||||||
|
Run the normal `scripts/check-bootstrap.py` gate and `scripts/check-ide-host.py` with the pinned host/target JDKs and IntelliJ installation described in the README. Qualification on 2026-11-13 passed: 75 Maven tests, 7 IntelliJ adapter tests, 34 target processes with 424 assertions, 48 worker lifecycle cycles, connected workflows and live shared UI in standalone and registered IntelliJ for classpath and module-path applications. Native isolation and worker shutdown passed with no new crash report. The live run also caught and fixed a canvas/list selection feedback loop; final logs contain no uncaught selection errors. The IDE companion checks use public UI and XML assertions across its separate plugin class loader. Local evidence: `/tmp/direct-binding-bootstrap.log` and `/tmp/direct-binding-registered-ide.log`.
|
||||||
@@ -104,4 +104,4 @@ Manual review disposition (2026-11-13): the user accepted proceeding after the r
|
|||||||
|
|
||||||
## Shared discovery: first stage-4 slice
|
## Shared discovery: first stage-4 slice
|
||||||
|
|
||||||
Implemented the [read-only binding browser](binding-discovery.md) in the shared Kit UI: target-side Person Attribute/path and collection-element discovery, Component Model declarations and direction-aware binding-target compatibility. Real classpath and module-path fixtures qualify both hosts. Binding creation/editing and the remaining metadata categories retain their subsequent work packages.
|
Implemented the [read-only binding browser](binding-discovery.md) in the shared Kit UI: target-side Person Attribute/path and collection-element discovery, Component Model declarations and direction-aware binding-target compatibility. Real classpath and module-path fixtures qualify both hosts. The [first editable DirectBinding inspector](direct-binding-inspector.md) adds creation, changing, removal and explicit conflict replacement through one source-preserving host undo command per operation. Remaining metadata categories and broader binding authoring retain their subsequent work packages.
|
||||||
|
|||||||
Reference in New Issue
Block a user