Show one Address component per address so the pane stays in step with the person's list.

A second component of the same class is a new injected instance, leaving the cached singleton in place, and selection stays an attribute of the person.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-30 18:17:47 +01:00
co-authored by Cursor
parent a89fe47133
commit 0321e0de8d
13 changed files with 642 additions and 31 deletions
@@ -11,8 +11,8 @@ import java.util.Map;
/**
* The list of binding elements declared inside {@code fx:define}.
* <p>
* The list is not a scene-graph child. FXML adds each {@link DirectBinding} or
* {@link ComputedBinding} child to {@link #getElements()}.
* The list is not a scene-graph child. FXML adds each {@link DirectBinding},
* {@link ComputedBinding}, or {@link Repeat} child to {@link #getElements()}.
*/
@DefaultProperty("elements")
public class BindingList {
@@ -29,8 +29,8 @@ public class BindingList {
/**
* Returns the binding elements in this list.
* <p>
* FXML appends child elements to the returned list. A child is a {@link DirectBinding} or a
* {@link ComputedBinding}.
* FXML appends child elements to the returned list. A child is a {@link DirectBinding}, a
* {@link ComputedBinding}, or a {@link Repeat}.
*
* @return the mutable list of binding elements
*/
@@ -54,6 +54,8 @@ public class BindingList {
DirectBindingApplier.apply(namespace, viewModel, directBinding, context);
} else if (element instanceof ComputedBinding computedBinding) {
ComputedBindingApplier.apply(namespace, viewModel, controller, root, computedBinding);
} else if (element instanceof Repeat repeat) {
RepeatApplier.apply(namespace, viewModel, repeat, context);
} else {
throw new IllegalArgumentException("Not a binding element: " + element);
}
@@ -31,7 +31,9 @@ final class Components {
* <p>
* An unknown type, such as a JavaFX control, makes {@link ApplicationContext#getComponent}
* throw {@link IllegalArgumentException}. This method returns null so FXML constructs that
* type itself. A failure while creating a known Component is not caught.
* type itself. A failure while creating a known Component is not caught. The first Component
* of a class in one load is the cached singleton. A second Component of that class is a new
* injected instance, so the document can show two of them.
*
* @param type the type FXML is about to construct
* @param context the container that creates Components
@@ -44,7 +46,38 @@ final class Components {
} catch (IllegalArgumentException notAComponent) {
return null;
}
return () -> load(component, context);
if (!LoadSession.claimFirst(type)) {
component = context.createComponent(type);
}
Object created = component;
return () -> load(created, context);
}
/**
* Creates a new Component and loads its document.
* <p>
* The instance is not the cached singleton. Its dependencies still come from
* {@link ApplicationContext#getComponent}. Bindings wait until {@link #setModel} runs.
*
* @param type the Component class
* @param context the container that creates the instance and its dependencies
* @return the loaded Component
*/
static Object fresh(Class<?> type, ApplicationContext context) {
Object component = context.createComponent(type.asSubclass(Object.class));
return load(component, context);
}
/**
* Sets the Component's model property.
* <p>
* A non-null value selects the ViewModel and then runs the Component's bindings.
*
* @param component the Component
* @param model the Model instance
*/
static void setModel(Object component, Object model) {
modelProperty(component).setValue(model);
}
private static Object load(Object component, ApplicationContext context) {
@@ -3,7 +3,9 @@ package de.neitzel.fx.view;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Holds the binding lists instantiated while one View is loading.
@@ -18,6 +20,7 @@ final class LoadSession {
private static final ThreadLocal<Deque<LoadSession>> STACK = new ThreadLocal<>();
private final List<BindingList> bindingLists = new ArrayList<>();
private final Set<Class<?>> built = new HashSet<>();
private LoadSession() {
}
@@ -39,20 +42,33 @@ final class LoadSession {
* Opens a nested session for a Component document.
* <p>
* Binding lists created until {@link #unnest()} register here, not on the parent session.
* A Component created after the parent load has finished, such as a repeated child added
* later, still gets a session so its own binding list can be captured.
*
* @return the nested session
*/
static LoadSession nest() {
Deque<LoadSession> stack = STACK.get();
if (stack == null) {
stack = new ArrayDeque<>();
STACK.set(stack);
}
LoadSession session = new LoadSession();
STACK.get().push(session);
stack.push(session);
return session;
}
/**
* Restores the session that was current before {@link #nest()}.
* <p>
* When the nested session was the only one, the thread-local stack is dropped.
*/
static void unnest() {
STACK.get().pop();
Deque<LoadSession> stack = STACK.get();
stack.pop();
if (stack.isEmpty()) {
STACK.remove();
}
}
/**
@@ -75,6 +91,23 @@ final class LoadSession {
stack.peek().bindingLists.add(bindingList);
}
/**
* Records the first Component of {@code type} built during this load.
* <p>
* Nested documents share the outermost session, so a second Component of the same class
* anywhere in the load is not the first.
*
* @param type the Component class
* @return {@code true} when this is the first time that class is built during the load
*/
static boolean claimFirst(Class<?> type) {
Deque<LoadSession> stack = STACK.get();
if (stack == null || stack.isEmpty()) {
return true;
}
return stack.peekLast().built.add(type);
}
/**
* Returns the lists registered during this session, in document order.
*
@@ -0,0 +1,69 @@
package de.neitzel.fx.view;
/**
* A repeat element declared in a {@link BindingList}.
* <p>
* The element names the pane by {@code fx:id}, the Component class, and the collection
* Attribute. The library keeps one Component child in that pane for each Model instance, in
* list order.
*/
public class Repeat {
private String pane;
private String component;
private String collection;
/**
* Sets the {@code fx:id} of the pane that holds the Component children.
*
* @param pane the pane id
*/
public void setPane(String pane) {
this.pane = pane;
}
/**
* Sets the Component class name. Each element gets a new instance of that class.
*
* @param component the class name
*/
public void setComponent(String component) {
this.component = component;
}
/**
* Sets the collection Attribute name. The list holds the Model instances.
*
* @param collection the attribute name
*/
public void setCollection(String collection) {
this.collection = collection;
}
/**
* Returns the {@code fx:id} of the pane.
*
* @return the pane id
*/
public String getPane() {
return pane;
}
/**
* Returns the Component class name.
*
* @return the class name
*/
public String getComponent() {
return component;
}
/**
* Returns the collection Attribute name.
*
* @return the attribute name
*/
public String getCollection() {
return collection;
}
}
@@ -0,0 +1,83 @@
package de.neitzel.fx.view;
import de.neitzel.injection.ApplicationContext;
import javafx.beans.property.Property;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.layout.Pane;
import java.util.Map;
/**
* Keeps one Component child in a pane for each Model instance in a collection.
*/
final class RepeatApplier {
private RepeatApplier() {
}
/**
* Fills the named pane with one new Component for each element, in list order.
* <p>
* Each Component is a new injected instance. The cached singleton of that class is left
* in place. The child's model property is the element. Adding or removing an element
* adds or removes the matching child. The list is the one the entity holds, so a change
* on either side is visible in the pane.
*
* @param namespace objects addressed by {@code fx:id}
* @param viewModel the ViewModel for this View
* @param repeat the declaration
* @param context the container that creates each Component
*/
static void apply(Map<String, Object> namespace, ViewModel viewModel, Repeat repeat,
ApplicationContext context) {
Object named = namespace.get(repeat.getPane());
if (!(named instanceof Pane pane)) {
throw new IllegalArgumentException("No pane \"" + repeat.getPane() + "\"");
}
Property<?> property = viewModel.property(repeat.getCollection());
if (property == null || !(property.getValue() instanceof ObservableList<?> values)) {
throw new IllegalArgumentException("No collection \"" + repeat.getCollection() + "\"");
}
Class<?> type = componentType(repeat.getComponent());
for (Object element : values) {
pane.getChildren().add(child(type, context, element));
}
listen(pane, type, context, values);
}
@SuppressWarnings("unchecked")
private static void listen(Pane pane, Class<?> type, ApplicationContext context, ObservableList<?> values) {
ObservableList<Object> elements = (ObservableList<Object>) values;
elements.addListener((ListChangeListener<Object>) change -> {
while (change.next()) {
if (change.wasPermutated() || change.wasUpdated()) {
continue;
}
if (change.getRemovedSize() > 0) {
pane.getChildren().remove(change.getFrom(), change.getFrom() + change.getRemovedSize());
}
for (int index = 0; index < change.getAddedSize(); index++) {
pane.getChildren().add(
change.getFrom() + index,
child(type, context, change.getAddedSubList().get(index)));
}
}
});
}
private static Node child(Class<?> type, ApplicationContext context, Object element) {
Object component = Components.fresh(type, context);
Components.setModel(component, element);
return (Node) component;
}
private static Class<?> componentType(String className) {
try {
return Class.forName(className);
} catch (ClassNotFoundException failure) {
throw new IllegalArgumentException("No component " + className, failure);
}
}
}
@@ -37,11 +37,21 @@ public final class ViewLoader {
* Attribute, with that Attribute's name as the source. A parent carries a copy with the source
* extended. It does not fail the load.
* A parent document names a Component class as an element. The context creates that Component
* and injects its services. The Component document {@code ClassName.fxml}, in the same package,
* is loaded with the Component as {@code fx:root} and as the Controller. That document's
* bindings are not applied during this load. They run when the Component's model property
* becomes a non-null Model. A Component that implements {@link HasViewModel} receives that
* ViewModel after the property is set and before those bindings run.
* and injects its services. A second element of the same class is another injected instance,
* and the cached singleton stays the first one. The Component document {@code ClassName.fxml},
* in the same package, is loaded with the Component as {@code fx:root} and as the Controller.
* That document's bindings are not applied during this load. They run when the Component's
* model property becomes a non-null Model. A Component that implements {@link HasViewModel}
* receives that ViewModel after the property is set and before those bindings run.
* A {@link Repeat} in the same list names a pane, a Component class, and a collection
* Attribute. One new Component child is placed in that pane for each Model instance, in list
* order. Adding or removing an element updates that pane. Those instances are created with
* {@link de.neitzel.injection.ApplicationContext#createComponent} so the cached singleton of
* that class stays in place. A collection of Model instances is an
* {@link javafx.collections.ObservableList} of those instances, shared with the entity.
* Selection is a separate Attribute of the parent Model. Null means nothing is selected.
* A Direct binding can tie a control property to that Attribute. The repeated Component does
* not store the selection.
* Loading does not open a popup or fill a status bar. A Direct binding whose path is
* {@code latestMessage} shows the latest Message text. {@link MessagePopup#showNewMessages}
* shows each newly added Message in a dialog.
@@ -54,11 +54,12 @@ import java.util.function.Consumer;
* Attribute, stores the ViewModel for the nested Model instance, or null when the Attribute
* is null. Strings, numbers, booleans, enums, {@link Date}, {@link Calendar}, and {@code java.time}
* values stay simple even when that type has setters. A collection of Model instances is not a
* Complex attribute. A list, set, or collection of simple values is an object property whose
* value is an {@link ObservableList} of those values. When that attribute's current value is a
* list, the observable list is backed by it and stored on the entity when the setter accepts it,
* so both sides hold one list. A cycle, or another Attribute that reaches the same instance,
* reuses that ViewModel.
* Complex attribute. It is an object property whose value is an {@link ObservableList} of those
* Model instances, not of ViewModels. A list, set, or collection of simple values is an object
* property whose value is an {@link ObservableList} of those values. When either attribute's
* current value is a list, the observable list is backed by it and stored on the entity when the
* setter accepts it, so both sides hold one list. A cycle, or another Attribute that reaches the
* same instance, reuses that ViewModel.
* The property is set from the Attribute when the ViewModel is created. A null number or
* boolean leaves the primitive property at its default. After that, a change of the property
* writes the Attribute. A Complex attribute writes the Model instance behind the ViewModel.
@@ -113,7 +114,7 @@ public class ViewModel {
properties.put(name, property);
complexAttributes.add(name);
} else {
if (isSimpleValueCollection(getter)) {
if (isSimpleValueCollection(getter) || isModelCollection(getter)) {
current = shareSimpleList(setter, name, current);
}
Property<?> property = createProperty(type, current);
@@ -545,6 +546,14 @@ public class ViewModel {
return element != null && isSimpleValue(element);
}
private static boolean isModelCollection(Method getter) {
if (!Collection.class.isAssignableFrom(getter.getReturnType())) {
return false;
}
Class<?> element = elementType(getter.getGenericReturnType());
return element != null && isComplex(element);
}
private static Class<?> elementType(Type type) {
if (!(type instanceof ParameterizedType parameterized) || parameterized.getActualTypeArguments().length != 1) {
return null;