Add reviewed FXML View generation in both hosts

This commit is contained in:
2026-11-13 21:39:06 +01:00
parent 1856f69637
commit 9ab1a4f530
18 changed files with 1463 additions and 9 deletions
@@ -0,0 +1,227 @@
package de.neitzel.fxdesigner.generation;
import de.neitzel.fxdesigner.protocol.BindingMetadata;
import java.util.*;
/** Pure, deterministic form proposals. Target discovery validates the resulting document. */
public final class ViewGeneration {
private ViewGeneration() {}
public enum Control {
TextField("text"),
Label("text"),
CheckBox("selected"),
ListView("items"),
Component("model"),
Repeat(""),
Table("");
private final String property;
Control(String property) {
this.property = property;
}
public String property() {
return property;
}
}
public record Column(String attribute, String header, String width) {}
public record Field(
String path,
String label,
Control control,
String direction,
String converter,
String component,
List<Column> columns,
boolean included) {
public Field {
columns = List.copyOf(columns);
}
@Override
public String toString() {
return (included ? "✓ " : "– ") + path + " · " + control;
}
}
public static List<Field> propose(BindingMetadata metadata) {
return metadata.attributes().stream()
.filter(
a ->
!a.path().contains(".")
&& Set.of("VALUE", "COMPLEX", "COLLECTION").contains(a.kind()))
.map(
a ->
new Field(
a.path(),
a.path(),
a.kind().equals("COMPLEX")
? Control.Component
: a.kind().equals("COLLECTION")
? (Set.of("java.lang.String", "java.lang.Integer", "java.lang.Boolean")
.contains(a.elementType())
? Control.ListView
: Control.Repeat)
: Set.of("boolean", "java.lang.Boolean").contains(a.type())
? Control.CheckBox
: Control.TextField,
"both",
"",
"",
List.of(),
true))
.toList();
}
public static String seed(String model, String controller) {
return generate(model, controller, List.of());
}
public static String id(int index) {
return "field" + (index + 1);
}
private static String type(String value) {
if (!value.matches("[A-Za-z_][\\w]*(\\.[A-Za-z_][\\w]*)+"))
throw new IllegalArgumentException("Choose a fully qualified class: " + value);
return value;
}
public static String xml(String value) {
for (int c : value.codePoints().toArray()) {
if ((c < 32 && c != '\n' && c != '\r' && c != '\t')
|| c == 0xfffe
|| c == 0xffff
|| (c >= 0xd800 && c <= 0xdfff))
throw new IllegalArgumentException("Invalid XML character");
}
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("\r", "&#13;")
.replace("\n", "&#10;")
.replace("\t", "&#9;");
}
public static String generate(String model, String controller, List<Field> fields) {
type(model);
if (!controller.isBlank()) type(controller);
var out =
new StringBuilder(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<?neitzel-model " + model + "?>\n");
for (String name :
List.of(
"javafx.scene.layout.GridPane",
"javafx.scene.layout.VBox",
"javafx.scene.control.Label",
"javafx.scene.control.TextField",
"javafx.scene.control.CheckBox",
"javafx.scene.control.ListView",
"de.neitzel.fx.view.BindingList",
"de.neitzel.fx.view.DirectBinding",
"de.neitzel.fx.view.Repeat",
"de.neitzel.fx.view.Table",
"de.neitzel.fx.view.Column")) out.append("<?import ").append(name).append("?>\n");
out.append(
"<GridPane xmlns:fx=\"http://javafx.com/fxml/1\" hgap=\"12\" vgap=\"10\""
+ " prefWidth=\"600\"");
if (!controller.isBlank()) out.append(" fx:controller=\"").append(controller).append('"');
out.append(">\n <fx:define><BindingList>\n");
var body = new StringBuilder();
var paths = new HashSet<String>();
int row = 0;
for (int i = 0; i < fields.size(); i++) {
var f = fields.get(i);
if (!f.included()) continue;
if (!f.path().matches("[A-Za-z_][\\w]*(\\.[A-Za-z_][\\w]*)*") || !paths.add(f.path()))
throw new IllegalArgumentException("Invalid or duplicate Attribute: " + f.path());
if (!Set.of("view", "model", "both").contains(f.direction()))
throw new IllegalArgumentException("Invalid binding direction");
String id = id(i), tag = f.control().name();
if (Set.of(Control.Component, Control.Repeat, Control.Table).contains(f.control()))
type(f.component());
if (!f.converter().isBlank()) type(f.converter());
if (f.control() == Control.Repeat) {
out.append(" <Repeat pane=\"")
.append(id)
.append("\" collection=\"")
.append(f.path())
.append("\" component=\"")
.append(f.component())
.append("\"/>\n");
tag = "VBox";
} else if (f.control() != Control.Table) {
out.append(" <DirectBinding control=\"")
.append(id)
.append("\" property=\"")
.append(f.control().property())
.append("\" path=\"")
.append(f.path())
.append("\" direction=\"")
.append(f.direction())
.append('"');
if (!f.converter().isBlank()) out.append(" converter=\"").append(f.converter()).append('"');
out.append("/>\n");
if (f.control() == Control.Component) tag = f.component();
}
boolean section =
Set.of(Control.Component, Control.Repeat, Control.Table, Control.ListView)
.contains(f.control());
int labelRow = row++, controlRow = section ? row++ : labelRow;
body.append(" <Label text=\"")
.append(xml(f.label()))
.append("\" GridPane.rowIndex=\"")
.append(labelRow)
.append("\" GridPane.columnIndex=\"0\" GridPane.columnSpan=\"")
.append(section ? 2 : 1)
.append("\"/>\n");
body.append(" <")
.append(tag)
.append(" fx:id=\"")
.append(id)
.append("\" GridPane.rowIndex=\"")
.append(controlRow)
.append("\" GridPane.columnIndex=\"")
.append(section ? 0 : 1)
.append("\" GridPane.columnSpan=\"")
.append(section ? 2 : 1)
.append("\" GridPane.hgrow=\"ALWAYS\"");
if (f.control() == Control.Table) {
if (f.columns().isEmpty())
throw new IllegalArgumentException("Choose Columns for " + f.path());
body.append(" collection=\"")
.append(f.path())
.append("\" row=\"")
.append(f.component())
.append("\">\n");
var cols = new HashSet<String>();
for (var c : f.columns()) {
if (!c.attribute().matches("[A-Za-z_][\\w]*") || !cols.add(c.attribute()))
throw new IllegalArgumentException("Invalid or duplicate Column Attribute");
body.append(" <Column attribute=\"")
.append(c.attribute())
.append("\" header=\"")
.append(xml(c.header()))
.append('"');
if (!c.width().isBlank()) {
double w = Double.parseDouble(c.width());
if (!Double.isFinite(w) || w <= 0)
throw new IllegalArgumentException("Column width must be positive");
body.append(" width=\"").append(c.width()).append('"');
}
body.append("/>\n");
}
body.append(" </Table>\n");
} else body.append("/>\n");
}
return out.append(" </BindingList></fx:define>\n")
.append(body)
.append("</GridPane>\n")
.toString();
}
}
@@ -26,6 +26,12 @@ public record HostServices(
public interface Documents {
CompletionStage<DocumentSession> open(URI document);
/** Explicit new-document creation; must never replace an existing path. */
default CompletionStage<DocumentSession> create(URI document, String text) {
return java.util.concurrent.CompletableFuture.failedFuture(
new UnsupportedOperationException("Host does not support new documents"));
}
/** Fired synchronously with host edits; listeners must not block the host thread. */
default AutoCloseable onChange(Consumer<URI> listener) {
return () -> {};
@@ -198,6 +198,30 @@ public final class DesignerWorkflow implements AutoCloseable {
return build.start(request, approved, line -> log.accept(line.line()));
}
public CompletionStage<String> currentBuildId(URI origin) {
return host.documents()
.snapshots()
.thenCompose(
drafts ->
CompletableFuture.supplyAsync(
() -> {
var overlays = new HashSet<URI>();
overlays.add(origin);
drafts.stream()
.map(DocumentSnapshot::document)
.filter(
uri ->
uri.toString().endsWith(".fxml")
&& Path.of(uri).startsWith(reactor))
.forEach(overlays::add);
try (var lease = build.previewCurrent(request, overlays)) {
return lease.snapshot().id();
} catch (Exception e) {
throw new CompletionException(e);
}
}));
}
public CompletionStage<BindingMetadata> bindings(
boolean last, DocumentSnapshot document, String path, String direction) {
return bindingDetails(last, document, path, direction, "", "");
@@ -0,0 +1,112 @@
package de.neitzel.fxdesigner.generation;
import static org.junit.jupiter.api.Assertions.*;
import de.neitzel.fxdesigner.generation.ViewGeneration.*;
import de.neitzel.fxdesigner.protocol.BindingMetadata;
import java.util.*;
import javax.xml.parsers.DocumentBuilderFactory;
import org.junit.jupiter.api.Test;
class ViewGenerationTest {
private Field field(
String path, Control control, String component, List<Column> columns, boolean include) {
return new Field(
path, "Name & <quoted> \"😀\"", control, "both", "", component, columns, include);
}
@Test
void emitsOrderedUniqueBindingsSectionsAndEscapedLabels() throws Exception {
var fields =
List.of(
field("name", Control.TextField, "", List.of(), true),
field("home", Control.Component, "sample.AddressForm", List.of(), true),
field("visits", Control.Repeat, "sample.AddressForm", List.of(), true));
String xml = ViewGeneration.generate("sample.Person", "", fields);
var doc =
DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(
new java.io.ByteArrayInputStream(
xml.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
assertEquals("GridPane", doc.getDocumentElement().getTagName());
assertEquals(
"Name & <quoted> \"😀\"",
((org.w3c.dom.Element) doc.getElementsByTagName("Label").item(0)).getAttribute("text"));
assertEquals(2, doc.getElementsByTagName("DirectBinding").getLength());
assertEquals(1, doc.getElementsByTagName("Repeat").getLength());
assertTrue(xml.indexOf("fx:id=\"field1\"") < xml.indexOf("fx:id=\"field2\""));
assertFalse(xml.contains("fx:controller"));
assertEquals(xml, ViewGeneration.generate("sample.Person", "", fields));
}
@Test
void omissionBypassesUnresolvedFieldsButIncludedFieldsMustBeConfigured() {
var missing = field("home", Control.Component, "", List.of(), true);
assertThrows(
IllegalArgumentException.class,
() -> ViewGeneration.generate("sample.Person", "", List.of(missing)));
assertDoesNotThrow(
() ->
ViewGeneration.generate(
"sample.Person",
"",
List.of(field("home", Control.Component, "", List.of(), false))));
assertThrows(
IllegalArgumentException.class, () -> ViewGeneration.seed("sample.Person?><bad>", ""));
assertThrows(IllegalArgumentException.class, () -> ViewGeneration.xml("bad\u0000"));
}
@Test
void tableColumnsKeepExplicitOrderAndValidateWidth() {
String xml =
ViewGeneration.generate(
"sample.Person",
"sample.Controller",
List.of(
field(
"visits",
Control.Table,
"sample.Row",
List.of(new Column("street", "Street", "120"), new Column("city", "City", "")),
true)));
assertTrue(xml.contains("fx:controller=\"sample.Controller\""));
assertTrue(xml.indexOf("attribute=\"street\"") < xml.indexOf("attribute=\"city\""));
assertTrue(xml.contains("width=\"120\""));
assertThrows(
IllegalArgumentException.class,
() ->
ViewGeneration.generate(
"sample.Person",
"",
List.of(
field(
"visits",
Control.Table,
"sample.Row",
List.of(new Column("street", "Street", "NaN")),
true))));
}
@Test
void proposalsUseSharedMetadataAndExcludeNestedPathsAndRuntimeMessages() {
var metadata =
new BindingMetadata(
"sample.Person",
"",
"both",
List.of(
new BindingMetadata.Attribute("name", "java.lang.String", "VALUE", ""),
new BindingMetadata.Attribute("home", "sample.Address", "COMPLEX", ""),
new BindingMetadata.Attribute("home.street", "java.lang.String", "VALUE", ""),
new BindingMetadata.Attribute(
"visits", "java.util.List", "COLLECTION", "sample.Address"),
new BindingMetadata.Attribute("latestMessage", "java.lang.String", "BUILTIN", "")),
List.of(),
List.of());
var fields = ViewGeneration.propose(metadata);
assertEquals(
List.of(Control.TextField, Control.Component, Control.Repeat),
fields.stream().map(Field::control).toList());
}
}