Add shared source-preserving Table and Column authoring

This commit is contained in:
2026-11-13 15:07:38 +01:00
parent 0d14ac14e0
commit 3417788194
9 changed files with 796 additions and 5 deletions
+1 -1
View File
@@ -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 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. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). Broader binding authoring, 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. Shared [Converter discovery and binding validation](docs/converter-validation.md) now adds capability-based proposals, document diagnostics and preview gating. [Custom ViewModel discovery and Computed-binding validation](docs/viewmodel-computed-validation.md) now follow the explicitly selected Controller or Component in both hosts. The [Computed-binding inspector](docs/computed-binding-inspector.md) now creates, changes and removes Computed declarations with method suggestions and shared source-preserving Undo/Redo. The [Repeat inspector](docs/repeat-binding-inspector.md) adds source-preserving collection declarations with host Undo/Redo and [collection/Component compatibility discovery](docs/repeat-compatibility.md). The shared [Table/Column inspector](docs/table-column-inspector.md) adds source-preserving Table configuration, ordered Column authoring and explicit Row navigation. Broader discovery, 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.
@@ -104,7 +104,7 @@ final class BindingDocument {
entries.add(new Entry(indexes.get(e), values(e), !hasElementChildren(e)));
}
private boolean named(Element e, String type) {
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 + "*");
@@ -372,7 +372,7 @@ final class BindingDocument {
return text.contains("\r\n") ? "\r\n" : "\n";
}
private Replacement append(Element container, String child, String attributes) {
Replacement append(Element container, String child, String attributes) {
var t = source.tags.get(indexes.get(container));
String eol = newline();
if (t.selfClosing())
@@ -45,6 +45,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
if (bindingEditor != null) bindingEditor.selectControl(id);
if (computedEditor != null) computedEditor.selectControl(id);
if (repeatEditor != null) repeatEditor.selectControl(id);
if (tableEditor != null) tableEditor.selectControl(id);
if (id == null || !projection.components().containsKey(id)) return;
componentInspector.getChildren().add(new Label(projection.components().get(id) + " · " + id));
componentInspector
@@ -117,6 +118,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private DesignerWorkflow bindingWorkflow;
private BindingBrowser bindingBrowser;
private BindingEditor bindingEditor, computedEditor, repeatEditor;
private TableEditor tableEditor;
private String validatedSource, validatingSource;
private boolean validationPreview;
private java.util.List<de.neitzel.fxdesigner.protocol.BindingMetadata.Problem> bindingProblems =
@@ -211,6 +213,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
if (target != null) kit.editor().getSelection().select(target);
},
values -> validateBindings(values, false));
tableEditor = new TableEditor(this::editTable, this::navigateComponent);
bindingBrowser =
new BindingBrowser(
this::discoverBindings,
@@ -361,9 +364,12 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
computedTab.setClosable(false);
var repeatTab = new Tab("Repeat bindings", repeatEditor);
repeatTab.setClosable(false);
var tableTab = new Tab("Table / Columns", tableEditor);
tableTab.setClosable(false);
var bindings =
new TitledPane(
"Bindings", new TabPane(directBindingTab, computedTab, repeatTab, discoveryTab));
"Bindings",
new TabPane(directBindingTab, computedTab, repeatTab, tableTab, discoveryTab));
bindings.setExpanded(false);
center.setBottom(new VBox(properties, bindings));
output.setEditable(false);
@@ -506,6 +512,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
bindingEditor.load(value.text(), value.writable());
computedEditor.load(value.text(), value.writable());
repeatEditor.load(value.text(), value.writable());
tableEditor.load(value.text(), value.writable());
submittedSource = null;
commitSource();
return;
@@ -530,6 +537,7 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
bindingEditor.load(value.text(), value.writable());
computedEditor.load(value.text(), value.writable());
repeatEditor.load(value.text(), value.writable());
tableEditor.load(value.text(), value.writable());
if (!source.getText().equals(value.text())) {
int anchor = source.getAnchor(), caret = source.getCaretPosition();
var edits =
@@ -685,6 +693,20 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
repeatEditor.invalidateDiscovery();
}
private void editTable(TableEditor.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 Table again");
submit(intent.edits(), intent.label());
} catch (Exception error) {
tableEditor.error(error.getMessage());
}
});
}
private void editBinding(BindingEditor.Intent intent) {
withDraft(
() -> {
@@ -715,7 +737,8 @@ public final class DesignerPane extends BorderPane implements AutoCloseable {
private boolean bindingPreviewAllowed() {
try {
var errors = new BindingDocument(draft.text()).errors();
var errors = new ArrayList<>(new BindingDocument(draft.text()).errors());
errors.addAll(new TableDocument(draft.text()).errors());
if (errors.isEmpty()) return true;
status.setText("Preview blocked: " + String.join("; ", errors));
if (projection != null)
@@ -0,0 +1,228 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import java.util.*;
import org.w3c.dom.*;
/** Table/Column intents over the authoritative XML, never over Kit's projection. */
final class TableDocument {
record Table(int index, String id, String collection, String row, boolean editable) {
public String toString() {
return (id.isEmpty() ? "Table #" + index : id) + " ← " + collection;
}
}
record Column(int index, String attribute, String header, String width, boolean editable) {
public String toString() {
return header + " (" + attribute + ")";
}
}
final BindingDocument xml;
final List<Table> tables = new ArrayList<>();
TableDocument(String text) throws Exception {
xml = new BindingDocument(text);
for (int i = 0; i < xml.elements.size(); i++) {
var e = xml.elements.get(i);
if (xml.named(e, "Table"))
tables.add(
new Table(
i,
e.getAttributeNS(BindingDocument.FX, "id"),
e.getAttribute("collection"),
e.getAttribute("row"),
!property(e, "collection") && !property(e, "row")));
}
}
private boolean property(Element e, String name) {
return children(e).stream().anyMatch(c -> c.getTagName().equals(name));
}
private static List<Element> children(Element e) {
var result = new ArrayList<Element>();
for (Node n = e.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof Element c) result.add(c);
return result;
}
private Element table(int index) {
if (tables.stream().noneMatch(t -> t.index() == index))
throw new IllegalArgumentException("Select a Table again");
return xml.elements.get(index);
}
private Element columnsContainer(int index) {
var e = table(index);
var wrappers = children(e).stream().filter(c -> c.getTagName().equals("columns")).toList();
if (wrappers.size() > 1
|| !wrappers.isEmpty() && children(e).stream().anyMatch(c -> xml.named(c, "Column")))
throw new IllegalArgumentException("Mixed Column containers; edit in Source");
return wrappers.isEmpty() ? e : wrappers.getFirst();
}
List<Column> columns(int index) {
return children(columnsContainer(index)).stream()
.filter(e -> xml.named(e, "Column"))
.map(
e ->
new Column(
xml.elements.indexOf(e),
e.getAttribute("attribute"),
e.getAttribute("header"),
e.getAttribute("width"),
children(e).isEmpty()))
.toList();
}
List<Replacement> configure(int index, String collection, String row) {
if (!tables.stream().filter(t -> t.index() == index).findFirst().orElseThrow().editable())
throw new IllegalArgumentException("Nested Table properties must be edited in Source");
return attributes(index, Map.of("collection", collection, "row", row));
}
List<Replacement> create(String collection, String row) {
var root = xml.document.getDocumentElement();
String name = root.getTagName();
if (BindingDocument.FX.equals(root.getNamespaceURI()) && root.getLocalName().equals("root"))
name = root.getAttribute("type");
if (!Set.of(
"VBox",
"HBox",
"Pane",
"AnchorPane",
"StackPane",
"FlowPane",
"TilePane",
"GridPane",
"javafx.scene.layout.VBox",
"javafx.scene.layout.HBox",
"javafx.scene.layout.Pane",
"javafx.scene.layout.AnchorPane",
"javafx.scene.layout.StackPane",
"javafx.scene.layout.FlowPane",
"javafx.scene.layout.TilePane",
"javafx.scene.layout.GridPane")
.contains(name))
throw new IllegalArgumentException(
"New Table requires a supported root Pane; insert it in Source for another parent");
Element parent =
children(root).stream()
.filter(c -> c.getTagName().equals("children"))
.findFirst()
.orElse(root);
String node =
"<de.neitzel.fx.view.Table collection=\""
+ escape(collection)
+ "\" row=\""
+ escape(row)
+ "\"/>";
return List.of(xml.append(parent, node, ""));
}
List<Replacement> removeTable(int index) {
if (table(index) == xml.document.getDocumentElement())
throw new IllegalArgumentException("A root Table cannot be removed; edit in Source");
return remove(index);
}
List<Replacement> writeColumn(
int table, Integer index, String attribute, String header, String width) {
if (index != null) {
var c = columns(table).stream().filter(v -> v.index() == index).findFirst().orElseThrow();
if (!c.editable())
throw new IllegalArgumentException("Nested Column properties must be edited in Source");
var values = new LinkedHashMap<String, String>();
values.put("attribute", attribute);
values.put("header", header);
values.put("width", width.isEmpty() ? null : width);
return attributes(index, values);
}
return List.of(
xml.append(
columnsContainer(table),
"<de.neitzel.fx.view.Column attribute=\""
+ escape(attribute)
+ "\" header=\""
+ escape(header)
+ "\""
+ (width.isEmpty() ? "" : " width=\"" + escape(width) + "\"")
+ "/>",
""));
}
List<Replacement> removeColumn(int table, int index) {
if (columns(table).stream().noneMatch(c -> c.index() == index))
throw new IllegalArgumentException("Select a Column again");
return remove(index);
}
List<Replacement> moveColumn(int table, int index, int delta) {
var columns = columns(table);
int from = -1;
for (int i = 0; i < columns.size(); i++) if (columns.get(i).index() == index) from = i;
if (from < 0) throw new IllegalArgumentException("Select a Column again");
int to = from + delta;
if (to < 0 || to >= columns.size()) return List.of();
var a = xml.source.tags.get(index);
var b = xml.source.tags.get(columns.get(to).index());
return List.of(
new Replacement(a.start(), a.end(), xml.text.substring(b.start(), b.end())),
new Replacement(b.start(), b.end(), xml.text.substring(a.start(), a.end())));
}
private List<Replacement> remove(int index) {
var t = xml.source.tags.get(index);
return List.of(new Replacement(t.start(), t.end(), ""));
}
private List<Replacement> attributes(int index, Map<String, String> values) {
var changed = new LinkedHashMap<String, String>();
var e = xml.elements.get(index);
values.forEach(
(k, v) -> {
if (v == null ? e.hasAttribute(k) : !v.equals(e.getAttribute(k))) changed.put(k, v);
});
return xml.source.attributes(index, changed);
}
private static String escape(String v) {
return XmlSource.escape(v, '"');
}
private static boolean path(String v) {
return v.matches(
"[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*(\\.[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*)*");
}
List<String> errors() {
var errors = new ArrayList<String>();
for (var t : tables) {
if (!t.editable()) continue;
if (!path(t.collection())) errors.add("Invalid Table collection: " + t);
if (!path(t.row())) errors.add("Invalid Table Row class: " + t);
try {
var columns = columns(t.index());
if (columns.isEmpty()) errors.add("Table needs at least one Column: " + t);
var seen = new HashSet<String>();
for (var c : columns) {
if (!c.editable()) continue;
if (!path(c.attribute())) errors.add("Invalid Column Attribute: " + c.attribute());
if (!seen.add(c.attribute())) errors.add("Duplicate Column Attribute: " + c.attribute());
if (c.header().isBlank()) errors.add("Column needs a header: " + c.attribute());
if (!c.width().isEmpty())
try {
double w = Double.parseDouble(c.width());
if (!Double.isFinite(w) || w <= 0) throw new NumberFormatException();
} catch (NumberFormatException e) {
errors.add("Column width must be positive and finite: " + c.attribute());
}
}
} catch (IllegalArgumentException e) {
errors.add(e.getMessage());
}
}
return List.copyOf(errors);
}
}
@@ -0,0 +1,249 @@
package de.neitzel.fxdesigner.kit;
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import java.util.List;
import java.util.function.Consumer;
import javafx.scene.control.*;
import javafx.scene.layout.*;
/** Shared Table and ordered Column form. Applying an intent is always a host document command. */
final class TableEditor extends VBox {
record Intent(String source, String label, List<Replacement> edits) {}
private final ListView<TableDocument.Table> tables = new ListView<>();
private final ListView<TableDocument.Column> columns = new ListView<>();
private final TextField collection = field("collection"),
row = field("row"),
attribute = field("attribute"),
header = field("header"),
width = field("width");
private final Label status = new Label();
private final Consumer<Intent> edit;
private String source = "";
private TableDocument document;
private boolean loading;
private Integer table, column;
TableEditor(Consumer<Intent> edit, Consumer<String> navigate) {
super(5);
this.edit = edit;
setId("table-editor");
tables.setId("table-bindings");
columns.setId("table-columns");
status.setId("table-status");
status.setWrapText(true);
tables.setPrefHeight(65);
columns.setPrefHeight(65);
tables
.getSelectionModel()
.selectedItemProperty()
.addListener(
(o, a, b) -> {
if (!loading && b != null) select(b);
});
columns
.getSelectionModel()
.selectedItemProperty()
.addListener(
(o, a, b) -> {
if (!loading && b != null) {
column = b.index();
attribute.setText(b.attribute());
header.setText(b.header());
width.setText(b.width());
}
});
var tableFields =
new HBox(6, new Label("Collection"), collection, new Label("Row Component"), row);
var columnFields =
new HBox(
6,
new Label("Attribute"),
attribute,
new Label("Header"),
header,
new Label("Width (optional)"),
width);
getChildren()
.addAll(
tables,
tableFields,
new HBox(
6,
button(
"New Table",
() -> {
tables.getSelectionModel().clearSelection();
table = null;
column = null;
columns.getItems().clear();
collection.clear();
row.clear();
}),
button(
"Apply Table",
() ->
change(
"Edit Table",
() ->
table == null
? document.create(collection.getText(), row.getText())
: document.configure(
table, collection.getText(), row.getText()))),
button(
"Remove Table",
() -> change("Remove Table", () -> document.removeTable(requireTable()))),
button(
"Open selected Row Component",
() -> {
if (!row.getText().isBlank()) navigate.accept(row.getText());
})),
columns,
columnFields,
new HBox(
6,
button(
"New Column",
() -> {
column = null;
columns.getSelectionModel().clearSelection();
attribute.clear();
header.clear();
width.clear();
}),
button(
"Apply Column",
() ->
change(
"Edit Column",
() ->
document.writeColumn(
requireTable(),
column,
attribute.getText(),
header.getText(),
width.getText()))),
button(
"Remove Column",
() ->
change(
"Remove Column",
() -> document.removeColumn(requireTable(), requireColumn()))),
button(
"Column up",
() ->
change(
"Move Column",
() -> document.moveColumn(requireTable(), requireColumn(), -1))),
button(
"Column down",
() ->
change(
"Move Column",
() -> document.moveColumn(requireTable(), requireColumn(), 1)))),
status);
}
private TextField field(String name) {
var f = new TextField();
f.setId("table-" + name);
return f;
}
private Button button(String text, Runnable action) {
var b = new Button(text);
b.setOnAction(e -> action.run());
return b;
}
private int requireTable() {
if (table == null) throw new IllegalArgumentException("Select a Table");
return table;
}
private int requireColumn() {
if (column == null) throw new IllegalArgumentException("Select a Column");
return column;
}
private void change(String label, java.util.concurrent.Callable<List<Replacement>> operation) {
try {
var edits = operation.call();
if (!edits.isEmpty()) edit.accept(new Intent(source, label, edits));
} catch (Exception e) {
error(e.getMessage());
}
}
private void select(TableDocument.Table value) {
table = value.index();
collection.setText(value.collection());
row.setText(value.row());
column = null;
attribute.clear();
header.clear();
width.clear();
try {
columns.getItems().setAll(document.columns(table));
} catch (Exception e) {
columns.getItems().clear();
error(e.getMessage());
}
}
void selectControl(String id) {
if (id == null || id.isEmpty()) return;
tables.getItems().stream()
.filter(t -> id.equals(t.id()))
.findFirst()
.ifPresent(t -> tables.getSelectionModel().select(t));
}
void load(String text, boolean writable) {
setDisable(!writable);
if (source.equals(text)) return;
source = text;
loading = true;
try {
document = new TableDocument(text);
tables.getItems().setAll(document.tables);
var selected =
document.tables.stream()
.filter(t -> table != null && t.index() == table)
.findFirst()
.orElse(null);
tables.getSelectionModel().select(selected);
columns.getItems().clear();
column = null;
if (selected != null) select(selected);
else {
table = null;
collection.clear();
row.clear();
attribute.clear();
header.clear();
width.clear();
}
var errors = document.errors();
status.setText(
(errors.isEmpty() ? "No structural Table/Column errors." : String.join("\n", errors))
+ "\n"
+ "Column/Row alignment and type compatibility are unverified. Open Row Component to"
+ " review its positional fields; Column edits never rearrange the shared Row"
+ " layout.");
} catch (Exception e) {
document = null;
tables.getItems().clear();
columns.getItems().clear();
table = null;
column = null;
error("Table editor unavailable: " + e.getMessage());
} finally {
loading = false;
}
}
void error(String message) {
status.setText(message);
}
}
@@ -675,6 +675,146 @@ public final class ProjectionHostChecks {
await(() -> fx(() -> source(pane).getText().equals(after)));
}
private static void tableField(DesignerPane pane, String field, String value) throws Exception {
fx(
() -> {
((TextField) pane.lookup("#table-" + field)).setText(value);
return null;
});
}
private static void tableSelect(DesignerPane pane, String list, int index) throws Exception {
fx(
() -> {
((ListView<?>) pane.lookup("#table-" + list)).getSelectionModel().select(index);
return null;
});
}
private static void tableEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
bindingTab(pane, "Table / Columns");
tableSelect(pane, "bindings", 0);
tableSelect(pane, "columns", 0);
tableField(pane, "header", "Street & address");
tableField(pane, "width", "180");
check(documentText(document).equals(full), "Table form changed source before Apply");
click(pane, "Apply Column");
await(() -> !documentText(document).equals(full));
String edited = documentText(document);
check(
edited.contains("header=\"Street &amp; address\"") && edited.contains("width=\"180\""),
"Column fields missing");
repeatHistory(pane, document, full, edited);
// A temporary duplicate is saveable, but cannot start a new preview.
tableSelect(pane, "bindings", 0);
click(pane, "New Column");
tableField(pane, "attribute", "street");
tableField(pane, "header", "Second");
click(pane, "Apply Column");
await(() -> !documentText(document).equals(edited));
String duplicate = documentText(document);
repeatHistory(pane, document, edited, duplicate);
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(duplicate),
"Table invalid draft not 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:")
&& l.getText().contains("Duplicate Column"))));
tableSelect(pane, "bindings", 0);
tableSelect(pane, "columns", 1);
click(pane, "Column up");
await(() -> !documentText(document).equals(duplicate));
String moved = documentText(document);
repeatHistory(pane, document, duplicate, moved);
check(
moved.indexOf("header=\"Second\"") < moved.indexOf("header=\"Street &amp; address\""),
"Column order not changed");
tableSelect(pane, "bindings", 0);
tableSelect(pane, "columns", 0);
click(pane, "Column down");
await(() -> documentText(document).equals(duplicate));
repeatHistory(pane, document, moved, duplicate);
tableSelect(pane, "bindings", 0);
tableSelect(pane, "columns", 1);
click(pane, "Remove Column");
await(() -> !documentText(document).equals(duplicate));
String removed = documentText(document);
repeatHistory(pane, document, duplicate, removed);
tableSelect(pane, "bindings", 0);
tableField(pane, "row", "fixture.components.AddressForm");
click(pane, "Apply Table");
await(() -> !documentText(document).equals(removed));
String configured = documentText(document);
repeatHistory(pane, document, removed, configured);
tableSelect(pane, "bindings", 0);
click(pane, "Remove Table");
await(() -> !documentText(document).equals(configured));
String noTable = documentText(document);
repeatHistory(pane, document, configured, noTable);
click(pane, "New Table");
tableField(pane, "collection", "visits");
tableField(pane, "row", "fixture.components.VisitRow");
click(pane, "Apply Table");
await(() -> !documentText(document).equals(noTable));
String created = documentText(document);
repeatHistory(pane, document, noTable, created);
tableSelect(pane, "bindings", 0);
click(pane, "New Column");
tableField(pane, "attribute", "street");
tableField(pane, "header", "Street");
tableField(pane, "width", "160");
click(pane, "Apply Column");
await(() -> !documentText(document).equals(created));
String valid = documentText(document);
repeatHistory(pane, document, created, valid);
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(valid),
"Table save changed source");
click(pane, "Interactive preview");
await(
() ->
fx(
() ->
pane.lookupAll(".label").stream()
.anyMatch(
n ->
n instanceof Label l
&& "Interactive preview running from captured drafts"
.equals(l.getText()))));
check(pane.interactivePid() > 0, "Table preview worker missing");
click(pane, "Stop preview");
await(() -> pane.interactivePid() < 0);
set(document, full);
await(() -> fx(() -> source(pane).getText().equals(full)));
System.out.println(
"PASS Table/Column inspector: CRUD, reorder, exact undo/redo, invalid save, preview gating"
+ " and target load");
}
private static void repeatBindingEdits(DesignerPane pane, DocumentSession document, String full)
throws Exception {
String base =
@@ -1273,6 +1413,7 @@ public final class ProjectionHostChecks {
directBindingEdits(pane, document, full);
computedBindingEdits(pane, document, full);
repeatBindingEdits(pane, document, full);
tableEdits(pane, document, full);
document
.save(document.snapshot().toCompletableFuture().get().revision())
.toCompletableFuture()
@@ -0,0 +1,121 @@
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 TableDocumentTest {
private static final String START =
"<?neitzel-model app.Person?>\r\n"
+ "<?import de.neitzel.fx.view.*?><?import javafx.scene.layout.VBox?><VBox"
+ " xmlns:f='http://javafx.com/fxml/1'><!-- keep --><Table f:id='t' collection = 'visits'"
+ " row='app.Row' custom='keep'><columns><!-- before --><Column header = 'Street'"
+ " attribute='street' width='80' custom='keep'/><!-- between --><Column header='Number'"
+ " attribute='number'/></columns></Table><Label text='untouched'/></VBox>";
private String apply(String text, List<EditBatch.Replacement> edits) throws Exception {
var result =
new EditBatch(1, "Table edit", edits)
.applyTo(new DocumentSnapshot(URI.create("file:///view.fxml"), 1, text, true, false));
new TableDocument(result);
return result;
}
@Test
void editsOnlyChangedRangesAndRemovesOptionalWidth() throws Exception {
var d = new TableDocument(START);
int t = d.tables.getFirst().index();
int c = d.columns(t).getFirst().index();
assertEquals(
START.replace("collection = 'visits'", "collection = 'other'"),
apply(START, d.configure(t, "other", "app.Row")));
assertTrue(d.configure(t, "visits", "app.Row").isEmpty());
var changed = apply(START, d.writeColumn(t, c, "street", "A'&<", ""));
assertEquals(
START.replace("header = 'Street'", "header = 'A&apos;&amp;&lt;'").replace("width='80'", ""),
changed);
}
@Test
void reordersExactElementsWithoutMovingCommentsOrRowSource() throws Exception {
var d = new TableDocument(START);
int t = d.tables.getFirst().index();
int c = d.columns(t).getFirst().index();
String moved = apply(START, d.moveColumn(t, c, 1));
assertTrue(
moved.contains(
"<!-- before --><Column header='Number' attribute='number'/><!-- between --><Column"
+ " header = 'Street' attribute='street' width='80' custom='keep'/>"));
var next = new TableDocument(moved);
assertEquals(START, apply(moved, next.moveColumn(t, next.columns(t).getFirst().index(), 1)));
assertTrue(d.moveColumn(t, c, -1).isEmpty());
}
@Test
void createsAndRemovesTablesAndColumnsInSupportedContainers() throws Exception {
for (String root :
List.of(
"<VBox/>",
"<VBox><children/></VBox>",
"<VBox><children><!--keep--></children></VBox>")) {
var d = new TableDocument(root);
String created = apply(root, d.create("visits", "app.Row"));
var n = new TableDocument(created);
int t = n.tables.getFirst().index();
String withColumn = apply(created, n.writeColumn(t, null, "street", "Street", "120"));
var c = new TableDocument(withColumn);
assertTrue(c.errors().isEmpty());
assertTrue(apply(withColumn, c.removeTable(t)).contains("VBox"));
String removed = apply(withColumn, c.removeColumn(t, c.columns(t).getFirst().index()));
assertTrue(
new TableDocument(removed).errors().stream().anyMatch(e -> e.contains("at least one")));
}
}
@Test
void preservesNestedDeclarationsAndRejectsAmbiguousContainers() throws Exception {
String text =
START.replace("width='80' custom='keep'/>", "width='80'><header>Nested</header></Column>");
var d = new TableDocument(text);
int t = d.tables.getFirst().index();
assertFalse(d.columns(t).getFirst().editable());
assertThrows(
IllegalArgumentException.class,
() -> d.writeColumn(t, d.columns(t).getFirst().index(), "street", "X", ""));
var mixed =
new TableDocument(
START.replace("<columns>", "<Column header='Other' attribute='other'/><columns>"));
assertThrows(
IllegalArgumentException.class, () -> mixed.columns(mixed.tables.getFirst().index()));
var root = new TableDocument("<de.neitzel.fx.view.Table collection='visits' row='app.Row'/>");
assertThrows(IllegalArgumentException.class, () -> root.removeTable(0));
assertThrows(
IllegalArgumentException.class,
() -> new TableDocument("<Label/>").create("visits", "app.Row"));
}
@Test
void diagnosesInvalidDeclarationsWithoutPreventingEdits() throws Exception {
for (String width : List.of("0", "-2", "NaN", "Infinity", "oops")) {
var d = new TableDocument(START);
int t = d.tables.getFirst().index();
var changed =
new TableDocument(
apply(START, d.writeColumn(t, d.columns(t).getFirst().index(), "number", "", width)));
assertTrue(changed.errors().stream().anyMatch(e -> e.contains("Duplicate")));
assertTrue(changed.errors().stream().anyMatch(e -> e.contains("positive and finite")));
assertTrue(changed.errors().stream().anyMatch(e -> e.contains("header")));
}
var invalid =
new TableDocument(START.replace("collection = 'visits'", "collection='invalid path'"));
assertFalse(invalid.errors().isEmpty());
}
@Test
void ignoresUnrelatedImportedNames() throws Exception {
assertTrue(new TableDocument("<?import app.Table?><VBox><Table/></VBox>").tables.isEmpty());
}
}
+4
View File
@@ -125,3 +125,7 @@ The [Repeat authoring slice](repeat-binding-inspector.md) adds create/change/rem
## Repeat collection and Component compatibility
The [shared Repeat discovery slice](repeat-compatibility.md) adds collection, pane and Component choices, non-initializing target-side type comparisons, source-preserving explicit selections and document diagnostics with preview gating. Runtime construction and values remain preview checks. Table/Column authoring, generated metadata, reference refactoring and Problems integration remain open.
## Shared Table / Column inspector
The [Table/Column authoring slice](table-column-inspector.md) adds Table and ordered Column CRUD, explicit reordering, optional widths and Row navigation in both hosts. Lexical edits and normal host Undo/Redo preserve the authoritative document. Structural errors block preview while drafts remain saveable. Row alignment and Table-specific type discovery remain unverified; generated metadata, reference refactoring and Problems integration remain open.
+25
View File
@@ -0,0 +1,25 @@
# Shared Table / Column authoring
Open **Bindings → Table / Columns** in either host. Select an existing Table or use **New Table**, edit its collection and Row Component class, then **Apply Table**. A new Table is inserted at the end of the root Pane's children (including an existing `children` wrapper). Other placements use Source. The root Pane must be one of the supported standard layout containers. Existing Tables can be configured wherever they occur in the document; a root Table cannot be removed through the inspector.
Columns expose Attribute, header and optional starting width. **New Column**, **Apply Column**, **Remove Column**, **Column up** and **Column down** each produce one host-owned undo command. Blank width removes the width attribute and restores the runtime's equal-share default. Table removal removes its complete declaration and Columns. It does not repair other binding references; those remain visible to existing diagnostics.
**Open selected Row Component** uses the same shared navigation service as canvas Component navigation. Column order is positional in NeitzelLib. Reordering Columns never rearranges or edits the shared Row FXML. The inspector explicitly marks Column/Row alignment and type compatibility as unverified and offers Row navigation for review. Detecting correspondence between Row fields and Column Attributes is a later discovery slice.
## Source and history
The authoritative host document remains the only persisted representation. Table and Column changes use lexical attribute/element ranges, preserving unrelated XML, comments, processing instructions, imports, namespaces, quote styles and unknown attributes. Reordering swaps complete Column element ranges; surrounding comments remain in their original positions. Both direct Column children and an explicit `columns` wrapper are supported. New declarations use fully qualified names and need no import changes.
Nested property syntax is retained and remains Source-only for field edits. Mixed Column containers are rejected rather than rewritten. Source changes invalidate the form's captured source; stale or read-only edits are refused. Each Apply, removal or move is one ordinary standalone/IntelliJ Undo/Redo transaction. Discovery or form typing does not change source.
Structural diagnostics cover missing/invalid collection and Row names, empty Column lists, invalid or duplicate Attributes, missing headers, and nonpositive/nonfinite widths. Invalid drafts remain editable and saveable, while these errors block new previews. Runtime collection types, simple Column Attribute types, Row construction and positional field alignment are not certified by this authoring slice; target preview remains the execution check. No application reflection or classes enter either host.
## Automated qualification
`TableDocumentTest` covers lexical preservation, escaping, width removal, direct/wrapped child creation, exact reorder behavior, unsupported nested declarations, ambiguous containers, root removal, unrelated imported names and invalid declarations. The shared live host scenario exercises Table/Column creation, changes, deletion, both reorder directions, single-step exact Undo/Redo, save, invalid-preview blocking and successful target loading. It runs in standalone and registered IntelliJ, each with classpath and named-module fixtures, through the existing bootstrap and IDE qualification commands.
No manual tests are required for this slice. Previously skipped human checks remain skipped. See the [bootstrap plan](repository-bootstrap-plan.md) and [Repeat discovery](repeat-compatibility.md) for preceding work and known automation reliability follow-ups.
Qualification on 2026-11-13 passed: 109 unit/adapter tests with no failures, 36 target processes with 498 fixture assertions, both native standalone modes, 48 lifecycle cycles, connected document adapters and plugin packaging. Registered IntelliJ classpath and module-path runs passed the shared Table/Column scenario, exclusive JavaFX 26 native-library checks and worker shutdown. No manual testing was used. The earlier intermittent save/render failures did not reproduce in these runs; this slice does not claim to fix them.
Local evidence: `/tmp/table-bootstrap.log`, `/tmp/table-registered-ide.log`, `target/connected-workflow/` and `target/ide-workflow/`. Generated qualification outputs are not repository inputs.