Add reviewed FXML View generation in both hosts
This commit is contained in:
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
.replace("\t", "	");
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user