Merge optional Message popups with child notices carried onto the parent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-30 18:04:39 +01:00
co-authored by Cursor
6 changed files with 356 additions and 3 deletions
@@ -43,10 +43,12 @@ public final class LoadedView {
/**
* Returns the JavaFX property for an Attribute name.
* <p>
* The property's class follows the Attribute's declared type.
* The property's class follows the Attribute's declared type. The name {@code latestMessage}
* is a string property holding the text of the latest Message.
*
* @param name the Attribute name
* @param name the Attribute name, or {@code latestMessage}
* @return the property, or {@code null} when that name is not a read-write Attribute
* and is not {@code latestMessage}
*/
public Property<?> property(String name) {
return viewModel.property(name);
@@ -0,0 +1,43 @@
package de.neitzel.fx.view;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
/**
* Shows each newly added {@link Message} in a dialog when the developer asks.
* <p>
* The library shows nothing until {@link #showNewMessages(ViewModel)} is called. An in-place
* update of an existing binding failure is not a new Message, so it does not open another dialog.
*/
public final class MessagePopup {
private MessagePopup() {
}
/**
* Shows each Message added to {@code viewModel} after this call in a dialog.
* <p>
* The dialog is shown with {@link Dialog#show()}, so the caller is not blocked. Messages
* already on the ViewModel stay where they are. A later in-place update of a binding failure
* does not open another dialog.
*
* @param viewModel the ViewModel whose new Messages should be shown
*/
public static void showNewMessages(ViewModel viewModel) {
viewModel.whenMessageAdded(MessagePopup::show);
}
/**
* Opens a dialog whose content is the Message text.
*
* @param message the notice to show
*/
private static void show(Message message) {
Dialog<Void> dialog = new Dialog<>();
dialog.setHeaderText(null);
dialog.getDialogPane().setContent(new Label(message.getText()));
dialog.getDialogPane().getButtonTypes().add(ButtonType.OK);
dialog.show();
}
}
@@ -42,6 +42,9 @@ public final class ViewLoader {
* 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.
* 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.
*
* @param fxml the FXML document
* @param model the Model instance
@@ -13,6 +13,7 @@ import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleLongProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -40,6 +41,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
/**
* The object a View binds to, built at runtime for one Model instance.
@@ -67,6 +69,10 @@ import java.util.Set;
* 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.
* The name {@code latestMessage} is a string property holding the text of the latest Message, so a
* Direct binding can show that text on a control. The library does not open a popup or fill a
* status bar unless the developer asks. {@link MessagePopup#showNewMessages} shows each newly added
* Message in a dialog. An in-place update does not.
*/
public class ViewModel {
@@ -79,6 +85,8 @@ public class ViewModel {
private final Map<String, Message> bindingFailures = new HashMap<>();
private final List<MessageListener> messageListeners = new ArrayList<>();
private final Map<String, ChildMessageMirror> childMirrors = new HashMap<>();
private final StringProperty latestMessageText = new SimpleStringProperty();
private final List<Consumer<Message>> messageAdded = new ArrayList<>();
private Message latestMessage;
/**
@@ -117,11 +125,19 @@ public class ViewModel {
/**
* Returns the JavaFX property for an Attribute name.
* <p>
* The name {@code latestMessage} is the text of the latest {@link Message}, beside the
* Model attributes. It is a string property. A Direct binding can read it. An in-place
* update of that Message refreshes the text and does not replace the Message.
*
* @param name the Attribute name
* @param name the Attribute name, or {@code latestMessage}
* @return the property, or {@code null} when that name is not a read-write Attribute
* and is not {@code latestMessage}
*/
public Property<?> property(String name) {
if ("latestMessage".equals(name)) {
return latestMessageText;
}
return properties.get(name);
}
@@ -345,8 +361,23 @@ public class ViewModel {
public void addMessage(Message message) {
messages.add(message);
latestMessage = message;
publishLatestMessage();
logSevere(message);
notifyAdded(message);
for (Consumer<Message> listener : List.copyOf(messageAdded)) {
listener.accept(message);
}
}
/**
* Calls {@code listener} each time a Message is added.
* <p>
* An in-place update of a binding failure does not call the listener.
*
* @param listener the listener
*/
void whenMessageAdded(Consumer<Message> listener) {
messageAdded.add(listener);
}
/**
@@ -357,7 +388,11 @@ public class ViewModel {
void addCarriedMessage(Message copy) {
messages.add(copy);
latestMessage = copy;
publishLatestMessage();
notifyAdded(copy);
for (Consumer<Message> listener : List.copyOf(messageAdded)) {
listener.accept(copy);
}
}
/**
@@ -371,6 +406,7 @@ public class ViewModel {
}
if (latestMessage == copy) {
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
publishLatestMessage();
}
notifyRemoved(copy);
}
@@ -413,6 +449,9 @@ public class ViewModel {
* @param message the notice
*/
void noteMessageText(Message message) {
if (message == latestMessage) {
publishLatestMessage();
}
notifyTextChanged(message);
}
@@ -449,6 +488,9 @@ public class ViewModel {
Message existing = bindingFailures.get(path);
if (existing != null) {
existing.setText(text);
if (existing == latestMessage) {
publishLatestMessage();
}
logSevere(existing);
notifyTextChanged(existing);
return;
@@ -471,10 +513,18 @@ public class ViewModel {
messages.remove(existing);
if (latestMessage == existing) {
latestMessage = messages.isEmpty() ? null : messages.get(messages.size() - 1);
publishLatestMessage();
}
notifyRemoved(existing);
}
/**
* Makes the string property for {@code latestMessage} match {@link #getLatestMessage()}.
*/
private void publishLatestMessage() {
latestMessageText.set(latestMessage == null ? null : latestMessage.getText());
}
/**
* Writes an error or fatal notice to the log. Info and warn are left out.
*
@@ -0,0 +1,240 @@
package de.neitzel.fx.view;
import de.neitzel.fx.view.fixture.Person;
import de.neitzel.injection.ApplicationContext;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.control.DialogPane;
import javafx.scene.control.Label;
import javafx.scene.control.Labeled;
import javafx.scene.control.TextField;
import javafx.scene.control.TextInputControl;
import javafx.stage.Window;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.fail;
class ShowMessagesWhenAskedTest {
@BeforeAll
static void startJavaFx() throws InterruptedException {
Platform.setImplicitExit(false);
CountDownLatch started = new CountDownLatch(1);
try {
Platform.startup(started::countDown);
} catch (IllegalStateException alreadyStarted) {
started.countDown();
}
if (!started.await(10, TimeUnit.SECONDS)) {
fail("JavaFX toolkit did not start");
}
}
@Test
void addingAMessageDoesNotOpenAPopupOrFillAStatusBar() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ShowMessagesWhenAskedTest.class.getResource("name-both-ways.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Parent root = loaded.getRoot();
Set<Node> nodesBefore = new HashSet<>(root.lookupAll("*"));
Set<Window> windowsBefore = snapshotWindows();
loaded.getViewModel().addMessage(
new Message(Message.Severity.INFO, "saved the person", "form"));
assertEquals(windowsBefore, snapshotWindows());
assertEquals(nodesBefore, new HashSet<>(root.lookupAll("*")));
assertMessageIsNotShown(root, "saved the person");
});
}
@Test
void directBindingShowsTheLatestMessageText() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ShowMessagesWhenAskedTest.class.getResource("latest-message.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Label messageLabel = (Label) loaded.getRoot().lookup("#messageLabel");
loaded.getViewModel().addMessage(
new Message(Message.Severity.INFO, "saved the person", "form"));
assertEquals("saved the person", messageLabel.getText());
loaded.getViewModel().addMessage(
new Message(Message.Severity.WARN, "name is required", "name"));
assertEquals("name is required", messageLabel.getText());
});
}
@Test
void optedInHelperShowsANewMessageInAPopup() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
URL fxml = ShowMessagesWhenAskedTest.class.getResource("name-both-ways.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
Set<Window> windowsBefore = snapshotWindows();
MessagePopup.showNewMessages(loaded.getViewModel());
assertEquals(windowsBefore, snapshotWindows());
try {
loaded.getViewModel().addMessage(
new Message(Message.Severity.INFO, "saved the person", "form"));
assertNotNull(windowShowing("saved the person", windowsBefore));
assertMessageIsNotShown(loaded.getRoot(), "saved the person");
} finally {
closeWindowsOpenedSince(windowsBefore);
}
});
}
@Test
void updatingABindingFailureDoesNotOpenAnotherPopup() throws Exception {
onFx(() -> {
Person person = new Person("Ada");
person.setAge(42);
URL fxml = ShowMessagesWhenAskedTest.class.getResource("age-both-converted.fxml");
assertNotNull(fxml);
ApplicationContext context = new ApplicationContext("de.neitzel.fx.view.fixture");
LoadedView loaded = ViewLoader.load(fxml, person, context);
TextField ageField = (TextField) loaded.getRoot().lookup("#ageField");
Set<Window> windowsBefore = snapshotWindows();
MessagePopup.showNewMessages(loaded.getViewModel());
try {
ageField.setText("nope");
ViewModel viewModel = loaded.getViewModel();
Message failure = viewModel.getLatestMessage();
assertEquals("not a whole number: nope", failure.getText());
assertEquals(1, windowsOpenedSince(windowsBefore).size());
assertNotNull(windowShowing("not a whole number: nope", windowsBefore));
ageField.setText("still-bad");
assertEquals(1, viewModel.getMessages().size());
assertSame(failure, viewModel.getLatestMessage());
assertEquals("not a whole number: still-bad", failure.getText());
assertEquals(1, windowsOpenedSince(windowsBefore).size());
viewModel.addMessage(new Message(Message.Severity.INFO, "saved the person", "form"));
assertEquals(2, windowsOpenedSince(windowsBefore).size());
assertNotNull(windowShowing("saved the person", windowsBefore));
assertEquals("saved the person", viewModel.getLatestMessage().getText());
} finally {
closeWindowsOpenedSince(windowsBefore);
}
});
}
private static Set<Window> snapshotWindows() {
return new HashSet<>(Window.getWindows());
}
private static Window windowShowing(String text, Set<Window> alreadyOpen) {
for (Window window : Window.getWindows()) {
if (!alreadyOpen.contains(window) && windowShows(window, text)) {
return window;
}
}
return null;
}
private static boolean windowShows(Window window, String text) {
if (window.getScene() == null || !(window.getScene().getRoot() instanceof Parent root)) {
return false;
}
if (root instanceof DialogPane pane && nodeShows(pane.getContent(), text)) {
return true;
}
for (Node node : root.lookupAll("*")) {
if (nodeShows(node, text)) {
return true;
}
}
return false;
}
private static boolean nodeShows(Node node, String text) {
return node instanceof Labeled labeled && text.equals(labeled.getText());
}
private static List<Window> windowsOpenedSince(Set<Window> alreadyOpen) {
List<Window> opened = new ArrayList<>();
for (Window window : Window.getWindows()) {
if (!alreadyOpen.contains(window)) {
opened.add(window);
}
}
return opened;
}
private static void closeWindowsOpenedSince(Set<Window> alreadyOpen) {
List<Window> open = new ArrayList<>(Window.getWindows());
for (Window window : open) {
if (!alreadyOpen.contains(window)) {
window.hide();
}
}
}
private static void assertMessageIsNotShown(Parent root, String text) {
for (Node node : root.lookupAll("*")) {
if (node instanceof Labeled labeled) {
assertNotEquals(text, labeled.getText());
}
if (node instanceof TextInputControl input) {
assertNotEquals(text, input.getText());
}
}
}
private static void onFx(FxAction action) throws Exception {
if (Platform.isFxApplicationThread()) {
action.run();
return;
}
AtomicReference<Throwable> thrown = new AtomicReference<>();
CountDownLatch done = new CountDownLatch(1);
Platform.runLater(() -> {
try {
action.run();
} catch (Throwable failure) {
thrown.set(failure);
} finally {
done.countDown();
}
});
if (!done.await(10, TimeUnit.SECONDS)) {
fail("JavaFX thread did not finish");
}
Throwable failure = thrown.get();
if (failure instanceof Exception exception) {
throw exception;
}
if (failure instanceof Error error) {
throw error;
}
if (failure != null) {
throw new AssertionError(failure);
}
}
@FunctionalInterface
private interface FxAction {
void run() throws Exception;
}
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.VBox?>
<?import de.neitzel.fx.view.BindingList?>
<?import de.neitzel.fx.view.DirectBinding?>
<VBox xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml">
<fx:define>
<BindingList>
<DirectBinding control="messageLabel" property="text" path="latestMessage" direction="view"/>
</BindingList>
</fx:define>
<Label fx:id="messageLabel" id="messageLabel"/>
</VBox>