Repository: ikmdev/komet
Pull request #488: Feature/Finished/IIA-2063: [Toast] Create POC of Toast control
Author: login: dukke
Created: 2025-05-26T21:40:08Z | Merged: 2025-05-28T14:01:16Z

Fixes: https://ikmdev.atlassian.net/browse/IIA-2062
--------------------------------------
Confluence Page: https://ikmdev.atlassian.net/wiki/spaces/IIA/pages/897286145/Toast+Control

Video of the toast showing up after a Semantic has been created:

https://github.com/user-attachments/assets/f6555cd3-6d2d-4e7e-9162-5fd2cc4857e2


Update:
Also fixes: 
- https://ikmdev.atlassian.net/browse/IIA-2068
- Fixes position of Toast being wrong sometimes



Diff:
diff --git a/kview/src/main/java/dev/ikm/komet/kview/controls/Toast.java b/kview/src/main/java/dev/ikm/komet/kview/controls/Toast.java
new file mode 100644
index 000000000..cd4e3864c
--- /dev/null
+++ b/kview/src/main/java/dev/ikm/komet/kview/controls/Toast.java
@@ -0,0 +1,187 @@
+package dev.ikm.komet.kview.controls;
+
+import javafx.animation.*;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import javafx.geometry.Point2D;
+import javafx.scene.Node;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.layout.*;
+import javafx.stage.Popup;
+import javafx.stage.Window;
+import javafx.util.Duration;
+
+/**
+ * A lightweight, animated Toast notification component for displaying transient feedback messages
+ * in JavaFX applications. Toasts are non-blocking, visually prominent, and automatically dismiss
+ * after a configurable duration.
+ *
+ * <p>This implementation supports:
+ * <ul>
+ *   <li>Success and failure status icons</li>
+ *   <li>Custom message text</li>
+ *   <li>Optional "UNDO" action button</li>
+ *   <li>A close (✕) button to dismiss manually</li>
+ * </ul>
+ *
+ * <p>Usage example:
+ * <pre>{@code
+ * toast
+ *     .withUndoAction(event -> handleUndo());
+ *     .show(Toast.Status.SUCCESS, "Data saved successfully!");
+ * }</pre>
+ *
+ */
+public class Toast {
+
+    /**
+     * Status enum indicating the type of message to display.
+     */
+    public enum Status {
+        SUCCESS, FAILURE
+    }
+
+    private static final int DEFAULT_AUTO_DISMISS_MILLIS = 10_000;
+
+    private final Node parent;
+    private final Popup popup;
+    private final HBox toastContainer;
+    private final StackPane iconContainer;
+    private final Region iconRegion;
+    private final Label messageLabel;
+    private final Button undoButton;
+    private final StackPane closeContainer;
+    private final Button closeButton;
+
+    /**
+     * Creates a new {@code Toast} bound to the given parent {@link Node}.
+     * The Toast will appear slightly above the bottom center of this node’s window.
+     *
+     * @param parent the JavaFX node over which the toast will display
+     */
+    public Toast(Node parent) {
+        this.parent = parent;
+
+        toastContainer = new HBox();
+        toastContainer.getStyleClass().add("toast");
+        toastContainer.setOpacity(0);
+
+        iconRegion = new Region();
+
+        iconContainer = new StackPane();
+        iconContainer.getChildren().setAll(iconRegion);
+
+        messageLabel = new Label();
+        messageLabel.getStyleClass().add("message");
+
+        undoButton = new Button("UNDO");
+        undoButton.getStyleClass().add("undo");
+
+        closeButton = new Button("✕");
+        closeButton.getStyleClass().add("close");
+
+        closeContainer = new StackPane();
+        closeContainer.getChildren().setAll(closeButton);
+        closeContainer.getStyleClass().add("close-container");
+
+        Region spacer = new Region();
+        HBox.setHgrow(spacer, Priority.ALWAYS);
+
+        toastContainer.getChildren().addAll(iconContainer, messageLabel, spacer, undoButton, closeContainer);
+        toastContainer.getStylesheets().add(Toast.class.getResource("toast.css").toExternalForm());
+
+        popup = new Popup();
+        popup.getContent().add(toastContainer);
+        popup.setAutoFix(true);
+        popup.setAutoHide(false);
+        popup.setHideOnEscape(true);
+
+        closeButton.setOnAction(e -> popup.hide());
+    }
+
+    /**
+     * Adds an {@link EventHandler} for the undo button.
+     * If set, the undo button will be shown in the Toast.
+     *
+     * @param undoAction the handler to invoke when the undo button is clicked
+     * @return this {@code Toast} instance for method chaining
+     */
+    public Toast withUndoAction(EventHandler<ActionEvent> undoAction) {
+        undoButton.setOnAction(undoAction);
+        return this;
+    }
+
+    /**
+     * Displays the Toast with the given status and message.
+     * The Toast will auto-dismiss after the default duration (10 seconds).
+     *
+     * @param status  the status icon to display (success or failure)
+     * @param message the message text to show in the toast
+     */
+    public void show(Status status, String message) {
+        show(status, message, DEFAULT_AUTO_DISMISS_MILLIS);
+    }
+
+    /**
+     * Displays the Toast with the given status, message, and auto-dismiss duration.
+     *
+     * @param status            the status icon to display (success or failure)
+     * @param message           the message text to show in the toast
+     * @param autoDismissMillis the time in milliseconds after which the toast auto-dismisses
+     */
+    public void show(Status status, String message, int autoDismissMillis) {
+        // Update content and style
+        iconRegion.getStyleClass().setAll("icon");
+        if (status == Status.SUCCESS) {
+            iconRegion.getStyleClass().add("icon-success");
+        } else {
+            iconRegion.getStyleClass().add("icon-failure");
+        }
+
+        messageLabel.setText(message);
+
+        boolean showUndo = undoButton.getOnAction() != null;
+        undoButton.setVisible(showUndo);
+        undoButton.setManaged(showUndo);
+
+        // Position relative to parent node
+        Window window = parent.getScene().getWindow();
+
+        Point2D parentScreenPos = parent.localToScreen(parent.getBoundsInLocal().getMinX(), parent.getBoundsInLocal().getMaxY());
+        // force layout pass to calculate dimensions
+        toastContainer.applyCss();
+        toastContainer.layout();
+
+        double centerX = parentScreenPos.getX() + parent.getBoundsInParent().getWidth() / 2 - toastContainer.prefWidth(-1) / 2;
+        double bottomY = parentScreenPos.getY() + parent.getBoundsInParent().getHeight() - 80;
+        popup.show(window, centerX, bottomY);
+
+        // Reset position and opacity for animation
+        toastContainer.setTranslateY(30);
+        toastContainer.setOpacity(0);
+
+        // Entrance animation
+        TranslateTransition slideIn = new TranslateTransition(Duration.millis(250), toastContainer);
+        slideIn.setFromY(30);
+        slideIn.setToY(0);
+        slideIn.setInterpolator(Interpolator.EASE_OUT);
+
+        FadeTransition fadeIn = new FadeTransition(Duration.millis(250), toastContainer);
+        fadeIn.setFromValue(0);
+        fadeIn.setToValue(1.0);
+        fadeIn.setInterpolator(Interpolator.EASE_OUT);
+
+        new ParallelTransition(slideIn, fadeIn).play();
+
+        FadeTransition fadeOut = new FadeTransition(Duration.millis(400), toastContainer);
+        fadeOut.setFromValue(1);
+        fadeOut.setToValue(0);
+        fadeOut.setOnFinished(_ -> popup.hide());
+
+        // Auto-dismiss
+        PauseTransition autoDismiss = new PauseTransition(Duration.millis(autoDismissMillis));
+        autoDismiss.setOnFinished(_ -> fadeOut.play());
+        autoDismiss.play();
+    }
+}
\ No newline at end of file
diff --git a/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/genediting/SemanticFieldsController.java b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/genediting/SemanticFieldsController.java
index db3f27eb5..64377d57b 100644
--- a/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/genediting/SemanticFieldsController.java
+++ b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/genediting/SemanticFieldsController.java
@@ -24,6 +24,7 @@
 import static dev.ikm.komet.kview.klfields.KlFieldHelper.createDefaultFieldValues;
 import static dev.ikm.komet.kview.klfields.KlFieldHelper.generateNode;
 import static dev.ikm.komet.kview.klfields.KlFieldHelper.retrieveCommittedLatestVersion;
+import static dev.ikm.komet.kview.mvvm.view.journal.JournalController.toast;
 import static dev.ikm.komet.kview.mvvm.viewmodel.FormViewModel.CREATE;
 import static dev.ikm.komet.kview.mvvm.viewmodel.FormViewModel.CURRENT_JOURNAL_WINDOW_TOPIC;
 import static dev.ikm.komet.kview.mvvm.viewmodel.FormViewModel.EDIT;
@@ -43,6 +44,7 @@
 import dev.ikm.komet.framework.observable.ObservableSemanticSnapshot;
 import dev.ikm.komet.framework.observable.ObservableSemanticVersion;
 import dev.ikm.komet.framework.view.ViewProperties;
+import dev.ikm.komet.kview.controls.Toast;
 import dev.ikm.komet.kview.events.genediting.GenEditingEvent;
 import dev.ikm.komet.kview.events.genediting.PropertyPanelEvent;
 import dev.ikm.komet.kview.events.pattern.PatternSavedEvent;
@@ -355,6 +357,14 @@ public void submit(ActionEvent actionEvent) {
                        EvtBusFactory.getDefaultEvtBus().publish(genEditingViewModel.getPropertyValue(CURRENT_JOURNAL_WINDOW_TOPIC),
                                new GenEditingEvent(actionEvent.getSource(), PUBLISH, list, semantic.nid()));
 //                       EntityService.get().beginLoadPhase();
+                       toast()
+                               .withUndoAction(undoActionEvent ->
+                                       LOG.info("undo called")
+                               )
+                               .show(
+                                       Toast.Status.SUCCESS,
+                                       "Semantic Details Added Successfully!"
+                               );
                    });
                });
            }, () -> {
diff --git a/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/journal/JournalController.java b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/journal/JournalController.java
index b5fba9d83..fdd302533 100644
--- a/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/journal/JournalController.java
+++ b/kview/src/main/java/dev/ikm/komet/kview/mvvm/view/journal/JournalController.java
@@ -78,6 +78,7 @@
 import dev.ikm.komet.kview.controls.KLSearchControl;
 import dev.ikm.komet.kview.controls.KLWorkspace;
 import dev.ikm.komet.kview.controls.NotificationPopup;
+import dev.ikm.komet.kview.controls.Toast;
 import dev.ikm.komet.kview.events.JournalTileEvent;
 import dev.ikm.komet.kview.events.MakeConceptWindowEvent;
 import dev.ikm.komet.kview.events.ShowNavigationalPanelEvent;
@@ -271,6 +272,8 @@ public class JournalController {
     @FXML
     private ContextMenu addContextMenu;
 
+    private static Toast toast;
+
 
     /////////////////////////////////////////////////////////////////
     // Private Data
@@ -434,6 +437,7 @@ public void initialize() {
 
         journalEventBus.subscribe(CALCULATOR_CACHE_TOPIC, RefreshCalculatorCacheEvent.class, refreshCalculatorEventSubscriber);
 
+        toast = new Toast(workspace);
     }
 
     private void onMouseClickedOnDesktopSurfacePane(MouseEvent mouseEvent) {
@@ -1596,4 +1600,7 @@ public void newCreateLidrWindow(ActionEvent actionEvent) {
     public void newCreatePatternWindow(ActionEvent actionEvent) {
         createPatternWindow(null, windowView.makeOverridableViewProperties());
     }
+
+    public static Toast toast() { return toast; }
+
 }
diff --git a/kview/src/main/resources/dev/ikm/komet/kview/controls/toast.css b/kview/src/main/resources/dev/ikm/komet/kview/controls/toast.css
new file mode 100644
index 000000000..d171459b8
--- /dev/null
+++ b/kview/src/main/resources/dev/ikm/komet/kview/controls/toast.css
@@ -0,0 +1,74 @@
+.toast {
+    -fx-background-color: #2e2e2e;
+    -fx-background-radius: 4;
+    -fx-border-radius: 4;
+    -fx-padding: 10px 10px 10px 14px;
+    -fx-spacing: 8px;
+
+    -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.4), 6, 0.3, 0, 1);
+
+    -fx-alignment: center_left;
+}
+
+.toast .message {
+    -fx-text-fill: white;
+    -fx-font-size: 14px;
+}
+
+.toast .icon {
+    size: 16px;
+    -fx-min-width: size;
+    -fx-min-height: size;
+    -fx-pref-width: size;
+    -fx-pref-height: size;
+    -fx-max-width: size;
+    -fx-max-height: size;
+}
+
+/* Success Icon */
+.toast .icon.icon-success {
+    -fx-shape: "M9.50016 16.1698L5.33016 11.9998L3.91016 13.4098L9.50016 18.9998L21.5002 6.99984L20.0902 5.58984L9.50016 16.1698Z";
+    -fx-background-color: #00bc62;
+
+    -fx-scale-shape: false;
+}
+
+/* Failure Icon */
+.toast .icon.icon-failure {
+    -fx-shape: "M1 1l18 18M1 19L19 1"; /* Replace with better X icon if needed */
+    -fx-background-color: red;
+}
+
+.toast .undo {
+    background_color: #4a7ad2;
+    -fx-background-color: background_color;
+    -fx-background-radius: 3px;
+
+    -fx-text-fill: white;
+    -fx-font-weight: bold;
+    -fx-border-color: transparent;
+}
+
+.toast .undo:hover {
+    -fx-background-color: derive(background_color, 7%);
+}
+
+.toast .close-container {
+    -fx-background-color: transparent;
+    -fx-background-radius: 3px;
+
+    -fx-padding: 0 3 0 3;
+}
+
+.toast .close-container:hover {
+    -fx-background-color: rgba(250, 250, 250, 0.1);
+}
+
+.toast .close {
+    -fx-background-color: transparent;
+    -fx-text-fill: white;
+    -fx-font-size: 14px;
+    -fx-border-color: transparent;
+
+    -fx-padding: 0;
+}
\ No newline at end of file


Reviews:
user: login: carldea
body: Looks amazing!

Can we create a ticket to update its Javadoc documentation?

state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T16:48:53Z
user: login: swaroopsalvi
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T18:45:30Z
user: login: carldea
body: Ready to go!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:58:15Z
user: login: dukke
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T21:36:08Z
user: login: carldea
body: Looks good to me.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T14:00:11Z

Review comments:
diff_hunk: @@ -356,6 +358,14 @@ public void submit(ActionEvent actionEvent) {
                                new GenEditingEvent(actionEvent.getSource(), PUBLISH, list, semantic.nid()));
 //                       EntityService.get().beginLoadPhase();
 
+                       toast()
+                           .withUndoAction(
+                                   toastUndoEvent -> System.out.println("Undo")
path: kview/src/main/java/dev/ikm/komet/kview/mvvm/view/genediting/SemanticFieldsController.java
user: login: swaroopsalvi
body: remove systemOutPrintln statements. what is the requirement for UNDO button. Will this set the version to the previously committed version?
created_at: 2025-05-27T18:44:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -356,6 +358,14 @@ public void submit(ActionEvent actionEvent) {
                                new GenEditingEvent(actionEvent.getSource(), PUBLISH, list, semantic.nid()));
 //                       EntityService.get().beginLoadPhase();
 
+                       toast()
+                           .withUndoAction(
+                                   toastUndoEvent -> System.out.println("Undo")
path: kview/src/main/java/dev/ikm/komet/kview/mvvm/view/genediting/SemanticFieldsController.java
user: login: dukke
body: @swaroopsalvi yes that makes sense. I'll change it to a logger call. 
Your questions make total sense... I added this just as a POC, since it was in the Figma but yeah undo in this case might not make sense. I was talking to @carldea  and he suggested we just leave it with a logger call, just so we can possibly show it and discuss.
created_at: 2025-05-27T21:36:08Z
author_association: CONTRIBUTOR

PR comments:
user: login: carldea
created_at: 2025-05-27T16:45:11Z
body: Great work as usual @dukke , 
Could you test in the JPRO server environment?
Let me know if you need help it setting up.
author_association: CONTRIBUTOR
user: login: dukke
created_at: 2025-05-27T16:46:46Z
body: > Great work as usual @dukke , Could you test in the JPRO server environment? Let me know if you need help it setting up.

@carldea 
Thanks a lot! GLad you like it.
Sounds good! I do need help setting up the JPRO environment 
author_association: CONTRIBUTOR
user: login: dukke
created_at: 2025-05-27T16:52:15Z
body: > Looks amazing!
> 
> Can we create a ticket to update its Javadoc documentation?

oh yes, good point, I forgot about Javadoc. I've created this ticket to track that: https://ikmdev.atlassian.net/browse/IIA-2068

author_association: CONTRIBUTOR
user: login: dukke
created_at: 2025-05-28T12:31:36Z
body: @carldea @swaroopsalvi made the changes requested. Added javadoc and fixed a bug where the position of the Toast could be wrong sometimes.
author_association: CONTRIBUTOR