Let a parent carry each child Message so a nested failure stays readable and clears with the field.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-30 18:02:36 +01:00
co-authored by Cursor
parent f8c65e444e
commit 01298fc730
12 changed files with 800 additions and 22 deletions
@@ -0,0 +1,127 @@
package de.neitzel.fx.view;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Carries one child ViewModel's current notices onto its parent.
* <p>
* Each copy is keyed by the identity of the child {@link Message}. A later text change on that
* child notice updates the same copy, and dropping the child notice drops the copy. The copy's
* source is the Attribute name, a dot, and the child source. A further parent carries that copy
* the same way, so the source grows into a Path. The copy is not logged again.
*/
final class ChildMessageMirror implements MessageListener {
private final ViewModel parent;
private final String attribute;
private final ViewModel child;
private final Map<Message, Message> copies = new IdentityHashMap<>();
private ChildMessageMirror(ViewModel parent, String attribute, ViewModel child) {
this.parent = parent;
this.attribute = attribute;
this.child = child;
}
/**
* Starts carrying {@code child} onto {@code parent} under {@code attribute}.
* <p>
* Notices already on the child are copied, and notices added later are copied as they arrive.
*
* @param parent the parent ViewModel
* @param attribute the Attribute that leads to the child
* @param child the child ViewModel
* @return the mirror, so it can be detached when the Attribute is cleared
*/
static ChildMessageMirror attach(ViewModel parent, String attribute, ViewModel child) {
ChildMessageMirror mirror = new ChildMessageMirror(parent, attribute, child);
child.addMessageListener(mirror);
for (Message message : List.copyOf(child.getMessages())) {
mirror.carry(message);
}
return mirror;
}
/**
* Stops carrying and drops this Attribute's copies from the parent.
*/
void detach() {
child.removeMessageListener(this);
List<Message> dropped = new ArrayList<>(copies.values());
copies.clear();
for (Message copy : dropped) {
parent.removeCarriedMessage(copy);
}
}
/**
* Copies {@code message} onto the parent unless this parent already holds it.
*
* @param message the child notice
*/
@Override
public void added(Message message) {
carry(message);
}
/**
* Drops the parent's copy of {@code message}.
*
* @param message the child notice
*/
@Override
public void removed(Message message) {
Message copy = copies.remove(message);
if (copy != null) {
parent.removeCarriedMessage(copy);
}
}
/**
* Writes the child's new text onto the same parent copy.
*
* @param message the child notice
*/
@Override
public void textChanged(Message message) {
Message copy = copies.get(message);
if (copy == null) {
return;
}
copy.setText(message.getText());
parent.noteMessageText(copy);
}
private void carry(Message message) {
if (copies.containsKey(message) || repeats(message)) {
return;
}
Message copy = message.withExtendedSource(attribute + "." + message.getSource());
copies.put(message, copy);
parent.addCarriedMessage(copy);
}
/**
* A cycle would carry a notice back onto a ViewModel that already holds it, or that holds
* the notice it was copied from.
*
* @param message the child notice
* @return {@code true} when the parent already has this notice or its origin
*/
private boolean repeats(Message message) {
Message current = message;
Set<Message> seen = Collections.newSetFromMap(new IdentityHashMap<>());
while (current != null && seen.add(current)) {
if (parent.getMessages().contains(current)) {
return true;
}
current = current.origin();
}
return false;
}
}
@@ -57,11 +57,11 @@ final class DirectBindingApplier {
Converter converter = converter(context, converterName);
switch (binding.getDirection()) {
case "both" -> alignConverted(
controlProperty, attribute, converter, viewModel, binding.getPath(), true, true);
controlProperty, attribute, converter, resolved.owner, resolved.name, true, true);
case "view" -> alignConverted(
controlProperty, attribute, converter, viewModel, binding.getPath(), true, false);
controlProperty, attribute, converter, resolved.owner, resolved.name, true, false);
case "model" -> alignConverted(
controlProperty, attribute, converter, viewModel, binding.getPath(), false, true);
controlProperty, attribute, converter, resolved.owner, resolved.name, false, true);
default -> throw new IllegalArgumentException("Not a direction: " + binding.getDirection());
}
}
@@ -76,15 +76,22 @@ final class DirectBindingApplier {
}
/**
* The Attribute a Path names, and whether that Attribute is complex.
* The Attribute a Path names, the ViewModel that owns it, and whether that Attribute is complex.
* <p>
* {@code name} is the last segment. A conversion failure is recorded on {@code owner} with that
* name as the source, so a parent only carries the extended copy.
*/
private static final class Resolved {
private final Property<?> attribute;
private final boolean complex;
private final ViewModel owner;
private final String name;
private Resolved(Property<?> attribute, boolean complex) {
private Resolved(Property<?> attribute, boolean complex, ViewModel owner, String name) {
this.attribute = attribute;
this.complex = complex;
this.owner = owner;
this.name = name;
}
}
@@ -123,7 +130,7 @@ final class DirectBindingApplier {
if (attribute == null) {
throw missing(path);
}
return new Resolved(attribute, current.isComplex(last));
return new Resolved(attribute, current.isComplex(last), current, last);
}
private static IllegalArgumentException missing(String path) {
@@ -143,31 +150,35 @@ final class DirectBindingApplier {
}
}
/**
* Aligns a converted Attribute. Failures are recorded on {@code owner} with {@code sourceName},
* which is the last name of the Path.
*/
@SuppressWarnings("unchecked")
private static void alignConverted(Property<?> controlProperty, Property<?> attribute, Converter converter,
ViewModel viewModel, String path, boolean towardView, boolean towardModel) {
ViewModel owner, String sourceName, boolean towardView, boolean towardModel) {
Property<Object> control = (Property<Object>) controlProperty;
Property<Object> source = (Property<Object>) attribute;
Link link = new Link();
pushControl(control, source, converter, viewModel, path, link);
pushControl(control, source, converter, owner, sourceName, link);
if (towardView) {
source.addListener((observable, oldValue, newValue) -> {
if (!link.applying) {
pushControl(control, source, converter, viewModel, path, link);
pushControl(control, source, converter, owner, sourceName, link);
}
});
}
if (towardModel) {
control.addListener((observable, oldValue, newValue) -> {
if (!link.applying) {
pushAttribute(control, source, converter, viewModel, path, link);
pushAttribute(control, source, converter, owner, sourceName, link);
}
});
}
}
private static void pushControl(Property<Object> control, Property<Object> attribute, Converter converter,
ViewModel viewModel, String path, Link link) {
ViewModel owner, String source, Link link) {
try {
Object shown = converter.toControlValue(attribute.getValue());
link.applying = true;
@@ -177,14 +188,14 @@ final class DirectBindingApplier {
} finally {
link.applying = false;
}
viewModel.clearBindingFailure(path);
owner.clearBindingFailure(source);
} catch (RuntimeException failure) {
viewModel.reportBindingFailure(path, failureText(failure));
owner.reportBindingFailure(source, failureText(failure));
}
}
private static void pushAttribute(Property<Object> control, Property<Object> attribute, Converter converter,
ViewModel viewModel, String path, Link link) {
ViewModel owner, String source, Link link) {
try {
Object converted = converter.toAttributeValue(control.getValue());
link.applying = true;
@@ -194,7 +205,7 @@ final class DirectBindingApplier {
} finally {
link.applying = false;
}
viewModel.clearBindingFailure(path);
owner.clearBindingFailure(source);
} catch (RuntimeException failure) {
link.applying = true;
try {
@@ -202,7 +213,7 @@ final class DirectBindingApplier {
} finally {
link.applying = false;
}
viewModel.reportBindingFailure(path, failureText(failure));
owner.reportBindingFailure(source, failureText(failure));
}
}
@@ -22,6 +22,7 @@ public final class Message {
private final Severity severity;
private String text;
private final String source;
private Message origin;
/**
* Creates a notice.
@@ -71,4 +72,28 @@ public final class Message {
void setText(String text) {
this.text = text;
}
/**
* Returns a copy whose source is extended for a parent ViewModel.
* <p>
* The copy remembers this notice, so a cycle does not carry it back onto the ViewModel
* that already holds it.
*
* @param source the extended source
* @return the copy
*/
Message withExtendedSource(String source) {
Message copy = new Message(severity, text, source);
copy.origin = this;
return copy;
}
/**
* Returns the notice this copy mirrors.
*
* @return that notice, or {@code null} when this notice was added on its own ViewModel
*/
Message origin() {
return origin;
}
}
@@ -0,0 +1,28 @@
package de.neitzel.fx.view;
/**
* Hears a notice added, dropped, or edited on a ViewModel.
*/
interface MessageListener {
/**
* A notice was added.
*
* @param message the notice
*/
void added(Message message);
/**
* A notice was dropped.
*
* @param message the notice
*/
void removed(Message message);
/**
* A notice kept its place and changed its text.
*
* @param message the notice
*/
void textChanged(Message message);
}
@@ -33,7 +33,9 @@ public final class ViewLoader {
* {@link javafx.collections.ObservableList}, so an add or remove is visible on both sides.
* A path that is not a read-write Attribute fails the load. The context creates a Controller,
* and a Converter when a Direct binding names one. A converter that cannot produce a value
* keeps the previous value and records one error Message. It does not fail the load.
* keeps the previous value and records one error Message on the ViewModel that owns the last
* Attribute, with that Attribute's name as the source. A parent carries a copy with the source
* extended. It does not fail the load.
*
* @param fxml the FXML document
* @param model the Model instance
@@ -64,7 +64,9 @@ import java.util.Set;
* <p>
* Beside those attributes, the ViewModel keeps a list of {@link Message}s and the latest one.
* The developer can add a message of any severity. Error and fatal messages are written to the log.
* Info and warn are not.
* Info and warn are not. A parent carries each current child message, with the source extended by
* the Attribute that leads to that child. Each further parent prepends its own Attribute. The
* carried copy is not logged again. When the child drops the message, the parent drops that copy.
*/
public class ViewModel {
@@ -75,6 +77,8 @@ public class ViewModel {
private final Set<String> complexAttributes = new HashSet<>();
private final List<Message> messages = new ArrayList<>();
private final Map<String, Message> bindingFailures = new HashMap<>();
private final List<MessageListener> messageListeners = new ArrayList<>();
private final Map<String, ChildMessageMirror> childMirrors = new HashMap<>();
private Message latestMessage;
/**
@@ -268,12 +272,43 @@ public class ViewModel {
}
private void watchComplex(Property<?> property, Method setter, String name) {
if (property.getValue() instanceof ViewModel child) {
attachChild(name, child);
}
property.addListener((observable, oldValue, newValue) -> {
if (oldValue != newValue) {
detachChild(name);
}
Object written = newValue instanceof ViewModel viewModel ? viewModel.model() : newValue;
write(setter, name, written);
if (oldValue != newValue && newValue instanceof ViewModel child) {
attachChild(name, child);
}
});
}
/**
* Carries the child ViewModel's current notices, extending each source with this Attribute.
*
* @param name the Attribute that leads to the child
* @param child the child ViewModel
*/
private void attachChild(String name, ViewModel child) {
childMirrors.put(name, ChildMessageMirror.attach(this, name, child));
}
/**
* Drops the copies carried for an Attribute and stops following that child.
*
* @param name the Attribute
*/
private void detachChild(String name) {
ChildMessageMirror mirror = childMirrors.remove(name);
if (mirror != null) {
mirror.detach();
}
}
private void write(Method setter, String name, Object value) {
try {
setter.invoke(model, value);
@@ -311,12 +346,103 @@ public class ViewModel {
messages.add(message);
latestMessage = message;
logSevere(message);
notifyAdded(message);
}
/**
* Records one error for a binding Path. A further failure of that Path replaces the text.
* Adds a copy of a child notice. The copy is not written to the log.
*
* @param path the binding Path
* @param copy the carried notice
*/
void addCarriedMessage(Message copy) {
messages.add(copy);
latestMessage = copy;
notifyAdded(copy);
}
/**
* Drops a carried copy. When it was the latest notice, the previous one becomes latest.
*
* @param copy the carried notice
*/
void removeCarriedMessage(Message copy) {
if (!messages.remove(copy)) {
return;
}
if (latestMessage == copy) {
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
}
notifyRemoved(copy);
}
/**
* Hears notices added on this ViewModel, including carried copies, and hears when they are
* dropped or their text changes.
*
* @param listener the listener
*/
void addMessageListener(MessageListener listener) {
messageListeners.add(listener);
}
/**
* Stops hearing notices added on this ViewModel.
*
* @param listener the listener
*/
void removeMessageListener(MessageListener listener) {
messageListeners.remove(listener);
}
/**
* Tells listeners that a notice was added.
*
* @param message the notice
*/
private void notifyAdded(Message message) {
for (MessageListener listener : List.copyOf(messageListeners)) {
listener.added(message);
}
}
/**
* Tells listeners that a notice kept its place and changed its text.
* <p>
* An in-place change does not log again and does not become a new latest notice.
*
* @param message the notice
*/
void noteMessageText(Message message) {
notifyTextChanged(message);
}
/**
* Tells listeners that a notice was dropped.
*
* @param message the notice
*/
private void notifyRemoved(Message message) {
for (MessageListener listener : List.copyOf(messageListeners)) {
listener.removed(message);
}
}
/**
* Tells listeners that a notice kept its place and changed its text.
*
* @param message the notice
*/
private void notifyTextChanged(Message message) {
for (MessageListener listener : List.copyOf(messageListeners)) {
listener.textChanged(message);
}
}
/**
* Records one error whose source is an Attribute name. A further failure of that Attribute
* replaces the text of the same notice. Parents that carry the notice hear the new text.
*
* @param path the Attribute name that owns the failure
* @param text the error text
*/
void reportBindingFailure(String path, String text) {
@@ -324,6 +450,7 @@ public class ViewModel {
if (existing != null) {
existing.setText(text);
logSevere(existing);
notifyTextChanged(existing);
return;
}
Message message = new Message(Message.Severity.ERROR, text, path);
@@ -332,9 +459,9 @@ public class ViewModel {
}
/**
* Drops the error recorded for a binding Path after a later conversion succeeds.
* Drops the error recorded for an Attribute after a later conversion succeeds.
*
* @param path the binding Path
* @param path the Attribute name that owned the failure
*/
void clearBindingFailure(String path) {
Message existing = bindingFailures.remove(path);
@@ -345,6 +472,7 @@ public class ViewModel {
if (latestMessage == existing) {
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
}
notifyRemoved(existing);
}
/**