diff --git a/.github/workflows/build_swagger.yml b/.github/workflows/build_swagger.yml
deleted file mode 100644
index 3d2f764132..0000000000
--- a/.github/workflows/build_swagger.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-name: build Swagger documentation
-
-on:
- push:
- branches: [ "main", "develop" ]
- pull_request:
- branches: [ "main", "develop" ]
-
-jobs:
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - name: Setup Maven and Java Action
- uses: s4u/setup-maven-action@v1.18.0
- with:
- java-version: '17'
- maven-version: '3.9.6'
- - name: Get swagger.json
- run: |
- cd ./services/alarm-logger
- mvn spring-boot:run &
- export jobpid="$!"
- sleep 30
- curl http://localhost:8080/v3/api-docs --output ../../docs/swagger.json
- kill "$jobpid"
- - name: Archive swagger.json
- uses: actions/upload-artifact@v4
- with:
- name: swagger.json
- path: docs/swagger.json
diff --git a/app/alarm/model/src/main/java/org/phoebus/applications/alarm/AlarmSystem.java b/app/alarm/model/src/main/java/org/phoebus/applications/alarm/AlarmSystem.java
index 07d64c9706..aca6d01f1b 100644
--- a/app/alarm/model/src/main/java/org/phoebus/applications/alarm/AlarmSystem.java
+++ b/app/alarm/model/src/main/java/org/phoebus/applications/alarm/AlarmSystem.java
@@ -115,6 +115,9 @@ public class AlarmSystem extends AlarmSystemConstants
/** Disable notify feature */
@Preference public static boolean disable_notify_visible;
+ /** Enable context menu for adding PVs to alarm configuration */
+ @Preference public static boolean enable_add_to_alarm_context_menu;
+
/** "Disable until.." shortcuts */
@Preference public static String[] shelving_options;
diff --git a/app/alarm/model/src/main/java/org/phoebus/applications/alarm/client/AlarmClient.java b/app/alarm/model/src/main/java/org/phoebus/applications/alarm/client/AlarmClient.java
index c07d56fc9d..34f5969cd6 100644
--- a/app/alarm/model/src/main/java/org/phoebus/applications/alarm/client/AlarmClient.java
+++ b/app/alarm/model/src/main/java/org/phoebus/applications/alarm/client/AlarmClient.java
@@ -484,12 +484,8 @@ private AlarmTreeItem> findOrCreateNode(final String path, final boolean is_le
* @param path_name to parent Root or parent component under which to add the component
* @param new_name Name of the new component
*/
- public void addComponent(final String path_name, final String new_name) {
- try {
- sendNewItemInfo(path_name, new_name, new AlarmClientNode(null, new_name));
- } catch (final Exception ex) {
- logger.log(Level.WARNING, "Cannot add component " + new_name + " to " + path_name, ex);
- }
+ public void addComponent(final String path_name, final String new_name) throws Exception {
+ sendNewItemInfo(path_name, new_name, new AlarmClientNode(null, new_name));
}
/**
@@ -498,12 +494,8 @@ public void addComponent(final String path_name, final String new_name) {
* @param path_name to parent Root or parent component under which to add the component
* @param new_name Name of the new component
*/
- public void addPV(final String path_name, final String new_name) {
- try {
- sendNewItemInfo(path_name, new_name, new AlarmClientLeaf(null, new_name));
- } catch (final Exception ex) {
- logger.log(Level.WARNING, "Cannot add pv " + new_name + " to " + path_name, ex);
- }
+ public void addPV(final String path_name, final String new_name) throws Exception {
+ sendNewItemInfo(path_name, new_name, new AlarmClientLeaf(null, new_name));
}
private void sendNewItemInfo(String path_name, final String new_name, final AlarmTreeItem> content) throws Exception {
diff --git a/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/AlarmTreePath.java b/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/AlarmTreePath.java
index e2b1643f05..4d4dd8924d 100644
--- a/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/AlarmTreePath.java
+++ b/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/AlarmTreePath.java
@@ -9,6 +9,7 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
/** Helper for handling the path names of alarm tree elements.
* Path looks like "/root/area/system/subsystem/pv_name".
@@ -32,24 +33,33 @@ public static boolean isPath(final String path)
* @param path Parent path or null when starting at root
* @param item Name of item at end of path
* @return Full path name to item
+ * @throws AlarmTreePathException When getting an illegal item string with leading slashes
*/
- public static String makePath(final String path, String item)
- {
+ public static String makePath(final String path, String item) throws AlarmTreePathException {
+ // Validate item: forbid leading slashes except exactly one (legacy compatibility)
+ if (item != null && item.startsWith(PATH_SEP)) {
+ // If there's more than one leading slash, it's invalid
+ if (item.length() > 1 && item.charAt(1) == PATH_SEP.charAt(0)) {
+ throw new AlarmTreePathException(
+ "Item must not have leading slashes: '" + item + "'"
+ );
+ }
+ // For legacy support (existing tests), strip exactly one leading slash
+ item = item.substring(1);
+ }
+
final StringBuilder result = new StringBuilder();
if (path != null)
{
if (! isPath(path))
result.append(PATH_SEP);
- // Skip path it it's only '/'
+ // Skip path if it's only '/'
if (!PATH_SEP.equals(path))
result.append(path);
}
result.append(PATH_SEP);
if (item != null && !item.isEmpty())
{
- // If item already starts with '/', skip it
- if (item.startsWith(PATH_SEP))
- item = item.substring(1);
// Escape any path-seps inside item with backslashes
result.append(item.replace(PATH_SEP, "\\/"));
}
@@ -112,8 +122,9 @@ public static String getName(final String path)
* @param path Original path
* @param modifier Path modifier: "segments/to/add", "/absolute/new/path", ".."
* @return Path based on pwd and modifier
+ * @throws AlarmTreePathException When a segment contains leading slashes.
*/
- public static String update(String path, String modifier)
+ public static String update(String path, String modifier) throws AlarmTreePathException
{
if (modifier == null || modifier.isEmpty())
return makePath(null, path);
@@ -137,4 +148,18 @@ public static String update(String path, String modifier)
}
}
}
+
+ public static boolean pathsAreEquivalent(String a, String b) {
+ var elementsA = splitPath(a);
+ var elementsB = splitPath(b);
+ if (elementsA.length != elementsB.length) {
+ return false;
+ }
+ for (int i = 0; i < elementsA.length; i++) {
+ if (!Objects.equals(elementsA[i], elementsB[i])) {
+ return false;
+ }
+ }
+ return true;
+ }
}
diff --git a/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/AlarmTreePathException.java b/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/AlarmTreePathException.java
new file mode 100644
index 0000000000..06f5dc7247
--- /dev/null
+++ b/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/AlarmTreePathException.java
@@ -0,0 +1,15 @@
+package org.phoebus.applications.alarm.model;
+
+/**
+ * Exception thrown when attempting to construct an invalid alarm tree path.
+ *
+ * Paths or path elements that start with more than one leading slash are not allowed.
+ * A single leading slash is permitted for backward compatibility and for
+ * representing absolute paths, but multiple leading slashes indicate an
+ * invalid or ambiguous path specification.
+ */
+public class AlarmTreePathException extends IllegalArgumentException {
+ public AlarmTreePathException(String message) {
+ super(message);
+ }
+}
diff --git a/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/xml/XmlModelReader.java b/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/xml/XmlModelReader.java
index fded541f13..74e840cd28 100644
--- a/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/xml/XmlModelReader.java
+++ b/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/xml/XmlModelReader.java
@@ -24,6 +24,7 @@
import org.phoebus.applications.alarm.client.AlarmClientLeaf;
import org.phoebus.applications.alarm.client.AlarmClientNode;
+import org.phoebus.applications.alarm.model.AlarmTreePathException;
import org.phoebus.applications.alarm.model.TitleDetail;
import org.phoebus.applications.alarm.model.TitleDetailDelay;
import org.phoebus.framework.persistence.XMLUtil;
@@ -118,15 +119,8 @@ private void buildModel(final Document doc) throws Exception
// Create the root of the model. Parent is null and name must be config.
root = new AlarmClientNode(null, root_node.getAttribute(TAG_NAME));
- // First add PVs at this level, ..
- for (final Element child : XMLUtil.getChildElements(root_node, TAG_PV))
- processPV(root /* parent */, child);
-
- // .. when sub-components which again have PVs.
- // This way, duplicate PVs will be detected and ignored at a nested level,
- // keeping those toward the root
- for (final Node child : XMLUtil.getChildElements(root_node, TAG_COMPONENT))
- processComponent(root /* parent */, child);
+ // Recursively process children
+ processChildren(root, root_node);
}
private void processComponent(final AlarmClientNode parent, final Node node) throws Exception
@@ -162,12 +156,34 @@ private void processComponent(final AlarmClientNode parent, final Node node) thr
// This does not refer to XML attributes but instead to the attributes of a model component node.
processCompAttr(component, node);
- // First add PVs at this level, then sub-components
+ // Recursively process children
+ processChildren(component, node);
+ }
+
+ private void processChildren(AlarmClientNode component, Node node) throws Exception {
+ // First add PVs at this level
for (final Element child : XMLUtil.getChildElements(node, TAG_PV))
- processPV(component/* parent */, child);
+ try {
+ processPV(component/* parent */, child);
+ } catch (AlarmTreePathException e) {
+ logger.log(Level.WARNING,
+ "Ignoring malformed PV "
+ + component.getPathName() + "/" + child.getAttribute(TAG_NAME) + ".\n"
+ + "Cause: " + e.getMessage());
+ }
+ // then subcomponents, which again have PVs.
+ // This way, duplicate PVs will be detected and ignored at a nested level,
+ // keeping those toward the root
for (final Element child : XMLUtil.getChildElements(node, TAG_COMPONENT))
- processComponent(component /* parent */, child);
+ try {
+ processComponent(component /* parent */, child);
+ } catch (AlarmTreePathException e) {
+ logger.log(Level.WARNING,
+ "Ignoring malformed component "
+ + component.getPathName() + "/" + child.getAttribute(TAG_NAME) + ".\n"
+ + "Cause: " + e.getMessage());
+ }
}
private void processCompAttr(final AlarmClientNode component, final Node node) throws Exception
diff --git a/app/alarm/model/src/main/resources/alarm_preferences.properties b/app/alarm/model/src/main/resources/alarm_preferences.properties
index 114a6d89dc..181866785f 100644
--- a/app/alarm/model/src/main/resources/alarm_preferences.properties
+++ b/app/alarm/model/src/main/resources/alarm_preferences.properties
@@ -162,6 +162,13 @@ connection_check_secs=5
# To turn on disable notifications feature, set the value to `true`
disable_notify_visible=false
+# Enable context menu for adding PVs to alarm configuration.
+#
+# When enabled, right-clicking on PVs in the application will show
+# an "Add to Alarms" option that opens a dialog to add the selected
+# PVs to the alarm tree.
+enable_add_to_alarm_context_menu=true
+
# Options for the "Disable until.." shortcuts in the PV config dialog.
#
# :format:
diff --git a/app/alarm/model/src/test/java/org/phoebus/applications/alarm/AlarmTreePathUnitTest.java b/app/alarm/model/src/test/java/org/phoebus/applications/alarm/AlarmTreePathUnitTest.java
new file mode 100644
index 0000000000..d374b45530
--- /dev/null
+++ b/app/alarm/model/src/test/java/org/phoebus/applications/alarm/AlarmTreePathUnitTest.java
@@ -0,0 +1,156 @@
+/*******************************************************************************
+ * Copyright (c) 2010 Oak Ridge National Laboratory.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ ******************************************************************************/
+package org.phoebus.applications.alarm;
+
+import org.junit.jupiter.api.Test;
+import org.phoebus.applications.alarm.model.AlarmTreePath;
+import org.phoebus.applications.alarm.model.AlarmTreePathException;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** JUnit test of AlarmTreePath
+ * @author Kay Kasemir
+ */
+@SuppressWarnings("nls")
+public class AlarmTreePathUnitTest
+{
+ @Test
+ public void testPathCheck()
+ {
+ assertThat(AlarmTreePath.makePath(null, "root"), equalTo("/root"));
+ assertThat(AlarmTreePath.makePath(null, "/root"), equalTo("/root"));
+ assertThat(AlarmTreePath.makePath("/", "/root"), equalTo("/root"));
+ assertThat(AlarmTreePath.isPath("/path/to/some/pv"), equalTo(true));
+ assertThat(AlarmTreePath.getName("/path/to/some/pv"), equalTo("pv"));
+ assertThat(AlarmTreePath.isPath("some_pv"), equalTo(false));
+ assertThat(AlarmTreePath.isPath("sim:\\/\\/sine"), equalTo(false));
+ assertThat(AlarmTreePath.getName("sim:\\/\\/sine"), equalTo("sim://sine"));
+ }
+
+ @Test
+ public void testInvalidLeadingSlashes() {
+ // Two or more leading slashes must throw
+ assertThrows(AlarmTreePathException.class, () -> AlarmTreePath.makePath(null, "//pv"));
+ assertThrows(AlarmTreePathException.class, () -> AlarmTreePath.makePath(null, "///pv"));
+
+ // Also when combined with parent paths
+ assertThrows(AlarmTreePathException.class, () -> AlarmTreePath.makePath("/", "//pv"));
+ assertThrows(AlarmTreePathException.class, () -> AlarmTreePath.makePath("/path", "//child"));
+ }
+
+ @Test
+ public void testMakePath()
+ {
+ // Split
+ String[] path = AlarmTreePath.splitPath("/path/to/some/pv");
+ assertThat(path.length, equalTo(4));
+ assertThat(path[1], equalTo("to"));
+
+ path = AlarmTreePath.splitPath("///path//to///some//pv");
+ assertThat(path.length, equalTo(4));
+ assertThat(path[1], equalTo("to"));
+
+
+ // Sub-path
+ final String new_path = AlarmTreePath.makePath(path, 2);
+ assertThat(new_path, equalTo("/path/to"));
+
+ // New PV
+ assertThat(AlarmTreePath.makePath(new_path, "another"),
+ equalTo("/path/to/another"));
+ }
+
+ @Test
+ public void testSpaces()
+ {
+ String path = AlarmTreePath.makePath("the path", "to");
+ assertThat(path, equalTo("/the path/to"));
+
+ path = AlarmTreePath.makePath(path, "an item");
+ assertThat(path, equalTo("/the path/to/an item"));
+
+ path = AlarmTreePath.makePath(path, "with / in it");
+ assertThat(path, equalTo("/the path/to/an item/with \\/ in it"));
+
+ // Split
+ final String[] items = AlarmTreePath.splitPath(path);
+ assertThat(items.length, equalTo(4));
+ assertThat(items[0], equalTo("the path"));
+ assertThat(items[1], equalTo("to"));
+ assertThat(items[2], equalTo("an item"));
+ assertThat(items[3], equalTo("with / in it"));
+
+ // Re-assemble
+ path = AlarmTreePath.makePath(items, items.length);
+ assertThat(path, equalTo("/the path/to/an item/with \\/ in it"));
+ }
+
+ @Test
+ public void testSpecialChars()
+ {
+ String path = AlarmTreePath.makePath("path", "to");
+ assertThat(path, equalTo("/path/to"));
+
+ // First element already contains '/'
+ path = AlarmTreePath.makePath("/path", "to");
+ assertThat(path, equalTo("/path/to"));
+
+ path = AlarmTreePath.makePath(path, "sim://sine");
+ // String is really "/path/to/sim:\/\/sine",
+ // but to get the '\' into the string,
+ // it itself needs to be escaped
+ assertThat(path, equalTo("/path/to/sim:\\/\\/sine"));
+
+ // Split
+ final String[] items = AlarmTreePath.splitPath(path);
+ assertThat(items.length, equalTo(3));
+ assertThat(items[0], equalTo("path"));
+ assertThat(items[1], equalTo("to"));
+ assertThat(items[2], equalTo("sim://sine"));
+
+ // Re-assemble
+ path = AlarmTreePath.makePath(items, items.length);
+ assertThat(path, equalTo("/path/to/sim:\\/\\/sine"));
+ }
+
+ @Test
+ public void testPathUpdate()
+ {
+ String path = AlarmTreePath.makePath("path", "to");
+ assertThat(path, equalTo("/path/to"));
+
+ path = AlarmTreePath.update(path, "sub");
+ assertThat(path, equalTo("/path/to/sub"));
+
+ path = AlarmTreePath.update(path, "..");
+ assertThat(path, equalTo("/path/to"));
+
+ path = AlarmTreePath.update(path, "/new/path");
+ assertThat(path, equalTo("/new/path"));
+
+ path = AlarmTreePath.update(null, "/path");
+ assertThat(path, equalTo("/path"));
+
+ path = AlarmTreePath.update(null, null);
+ assertThat(path, equalTo("/"));
+
+ path = AlarmTreePath.update("/", "..");
+ assertThat(path, equalTo("/"));
+
+ path = AlarmTreePath.update("/", "path");
+ assertThat(path, equalTo("/path"));
+
+ path = AlarmTreePath.update("/", "path/to/sub");
+ assertThat(path, equalTo("/path/to/sub"));
+
+ path = AlarmTreePath.update("/", "path/to\\/sub");
+ assertThat(path, equalTo("/path/to\\/sub"));
+ }
+}
\ No newline at end of file
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/table/AlarmInfoRow.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/table/AlarmInfoRow.java
index cabb3e3209..9b3db017dd 100644
--- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/table/AlarmInfoRow.java
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/table/AlarmInfoRow.java
@@ -114,14 +114,14 @@ public String toString()
{
final StringBuilder buf = new StringBuilder();
- buf.append("PV: ").append(pv.get()).append("\n");
- buf.append("Description: ").append(description.get()).append("\n");
- buf.append("Alarm Time: ").append(time.get()).append("\n");
+ buf.append("PV: ").append(pv.get()).append(" \n");
+ buf.append("Description: ").append(description.get()).append(" \n");
+ buf.append("Alarm Time: ").append(time.get()).append(" \n");
buf.append("Alarm Severity: ").append(severity.get());
buf.append(", Status: ").append(status.get());
- buf.append(", Value: ").append(value.get()).append("\n");
+ buf.append(", Value: ").append(value.get()).append(" \n");
buf.append("Current PV Severity: ").append(pv_severity.get());
- buf.append(", Status: ").append(pv_status.get()).append("\n");
+ buf.append(", Status: ").append(pv_status.get()).append(" \n");
return buf.toString();
}
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/table/AlarmTableUI.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/table/AlarmTableUI.java
index 624bd8f5a8..70c2c2a8da 100644
--- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/table/AlarmTableUI.java
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/table/AlarmTableUI.java
@@ -597,7 +597,7 @@ private String list_alarms()
buf.append("Acknowledged Alarms\n");
buf.append("===================\n");
- for (AlarmInfoRow row : active_rows)
+ for (AlarmInfoRow row : acknowledged_rows)
buf.append(row).append("\n");
return buf.toString();
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AddComponentAction.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AddComponentAction.java
index 34dd990263..6493b9ab9b 100644
--- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AddComponentAction.java
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AddComponentAction.java
@@ -174,7 +174,13 @@ public AddComponentAction(final Node node, final AlarmClient model, final AlarmT
// Check for duplicate PV, anywhere in alarm tree
final String existing = findPV(root, pv);
if (existing == null)
- model.addPV(parent.getPathName(), pv);
+ try {
+ model.addPV(parent.getPathName(), pv);
+ } catch (Exception e) {
+ ExceptionDetailsErrorDialog.openError(Messages.error,
+ Messages.addComponentFailed,
+ e);
+ }
else
{
Platform.runLater(() ->
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigDialog.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigDialog.java
new file mode 100644
index 0000000000..0667f5133d
--- /dev/null
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigDialog.java
@@ -0,0 +1,138 @@
+package org.phoebus.applications.alarm.ui.tree;
+
+import javafx.beans.value.ChangeListener;
+import javafx.geometry.Insets;
+import javafx.scene.control.*;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.VBox;
+import org.phoebus.applications.alarm.client.AlarmClient;
+import org.phoebus.applications.alarm.model.AlarmTreeItem;
+import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog;
+
+import java.util.Optional;
+
+/**
+ * Dialog for configuring alarm tree items with path selection.
+ * Displays the alarm tree structure and allows user to select a path from the tree.
+ */
+public class AlarmTreeConfigDialog extends Dialog
+{
+ private final TextField pathInput;
+
+ /**
+ * Constructor for AlarmTreeConfigDialog
+ *
+ * @param alarmClient The alarm client model to display the tree
+ * @param currentPath The current path (initial value for text input)
+ * @param title The title of the dialog
+ * @param headerText The header text of the dialog
+ */
+ public AlarmTreeConfigDialog(AlarmClient alarmClient, String currentPath, String title, String headerText)
+ {
+ setTitle(title);
+ setHeaderText(headerText);
+ setResizable(true);
+
+ // Create content
+ VBox content = new VBox(10);
+ content.setPadding(new Insets(15));
+
+ // Add AlarmTreeConfigView
+ AlarmTreeConfigView configView = new AlarmTreeConfigView(alarmClient);
+ configView.setPrefHeight(300);
+ configView.setPrefWidth(400);
+
+ // Initialize path input first
+ pathInput = new TextField();
+ pathInput.setText(currentPath != null ? currentPath : "");
+ pathInput.setStyle("-fx-font-family: monospace;");
+ pathInput.setPromptText("Select a path from the tree above or type manually");
+ pathInput.setEditable(true);
+
+ // Extract the last segment from the initial currentPath to preserve across selections
+ final String selectedTreeItem;
+ if (currentPath != null && !currentPath.isEmpty()) {
+ int lastSlashIndex = currentPath.lastIndexOf('/');
+ if (lastSlashIndex >= 0 && lastSlashIndex < currentPath.length() - 1) {
+ selectedTreeItem = currentPath.substring(lastSlashIndex + 1);
+ } else {
+ selectedTreeItem = "";
+ }
+ } else {
+ selectedTreeItem = "";
+ }
+
+ // Store the listener in a variable
+ ChangeListener>> selectionListener = (obs, oldVal, newVal) -> {
+ if (newVal != null && newVal.getValue() != null)
+ {
+ String selectedPath = newVal.getValue().getPathName();
+ if (selectedPath != null && !selectedPath.isEmpty())
+ {
+ // Only update if not focused
+ if (!pathInput.isFocused()) {
+ // Append the preserved last segment to the selected path
+ String newPath = selectedPath;
+ if (!selectedTreeItem.isEmpty()) {
+ newPath = selectedPath + "/" + selectedTreeItem;
+ }
+
+ pathInput.setText(newPath);
+ }
+ }
+ }
+ };
+ configView.addTreeSelectionListener(selectionListener);
+ // Remove the listener when the dialog is closed
+ this.setOnHidden(e -> configView.removeTreeSelectionListener(selectionListener));
+
+ // Add text input for path
+ Label pathLabel = new Label("Selected Path:");
+
+ content.getChildren().addAll(
+ configView,
+ pathLabel,
+ pathInput
+ );
+
+ // Make tree view grow to fill available space
+ VBox.setVgrow(configView, Priority.ALWAYS);
+
+ getDialogPane().setContent(content);
+ getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
+ getDialogPane().setPrefSize(500, 600);
+
+ // Set result converter
+ setResultConverter(this::handleResult);
+ }
+
+ /**
+ * Handle the dialog result
+ */
+ private String handleResult(ButtonType buttonType)
+ {
+ if (buttonType == ButtonType.OK)
+ {
+ String path = pathInput.getText().trim();
+ if (path.isEmpty())
+ {
+ ExceptionDetailsErrorDialog.openError("Invalid Path",
+ "Path cannot be empty.",
+ null);
+ return null;
+ }
+ return path;
+ }
+ return null;
+ }
+
+ /**
+ * Show the dialog and get the result
+ *
+ * @return Optional containing the path if OK was clicked, empty otherwise
+ */
+ public Optional getPath()
+ {
+ return showAndWait();
+ }
+}
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigView.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigView.java
new file mode 100644
index 0000000000..45b99ff4e5
--- /dev/null
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigView.java
@@ -0,0 +1,574 @@
+/*******************************************************************************
+ * Copyright (c) 2018-2023 Oak Ridge National Laboratory.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *******************************************************************************/
+package org.phoebus.applications.alarm.ui.tree;
+
+import javafx.application.Platform;
+import javafx.beans.value.ChangeListener;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.geometry.Insets;
+import javafx.scene.Cursor;
+import javafx.scene.Node;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.SelectionMode;
+import javafx.scene.control.ToolBar;
+import javafx.scene.control.Tooltip;
+import javafx.scene.control.TreeItem;
+import javafx.scene.control.TreeView;
+import javafx.scene.layout.Background;
+import javafx.scene.layout.BackgroundFill;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.CornerRadii;
+import javafx.scene.paint.Color;
+import org.phoebus.applications.alarm.AlarmSystem;
+import org.phoebus.applications.alarm.client.AlarmClient;
+import org.phoebus.applications.alarm.client.AlarmClientLeaf;
+import org.phoebus.applications.alarm.client.AlarmClientListener;
+import org.phoebus.applications.alarm.model.AlarmTreeItem;
+import org.phoebus.applications.alarm.model.BasicState;
+import org.phoebus.applications.alarm.ui.AlarmUI;
+import org.phoebus.ui.javafx.ImageCache;
+import org.phoebus.ui.javafx.ToolbarHelper;
+import org.phoebus.ui.javafx.UpdateThrottle;
+import org.phoebus.util.text.CompareNatural;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.logging.Level;
+
+import static org.phoebus.applications.alarm.AlarmSystem.logger;
+
+/** Tree-based UI for alarm configuration
+ *
+ *
Implemented as {@link BorderPane}, but should be treated
+ * as generic JavaFX Node, only calling public methods
+ * defined on this class.
+ *
+ * @author Kay Kasemir
+ */
+@SuppressWarnings("nls")
+public class AlarmTreeConfigView extends BorderPane implements AlarmClientListener
+{
+ private final Label no_server = AlarmUI.createNoServerLabel();
+ private final TreeView> tree_config_view = new TreeView<>();
+
+ /** Model with current alarm tree, sends updates */
+ private final AlarmClient model;
+ private final String itemName;
+
+ /** Latch for initially pausing model listeners
+ *
+ * Imagine a large alarm tree that changes.
+ * The alarm table can periodically display the current
+ * alarms, it does not need to display every change right away.
+ * The tree on the other hand must reflect every added or removed item,
+ * because updates cannot be applied once the tree structure gets out of sync.
+ * When the model is first started, there is a flurry of additions and removals,
+ * which arrive in the order in which the tree was generated, not necessarily
+ * in the order they're laid out in the hierarchy.
+ * These can be slow to render, especially if displaying via a remote desktop (ssh-X).
+ * The alarm tree view thus starts in stages:
+ * 1) Wait for model to receive the bulk of initial additions and removals
+ * 2) Add listeners to model changes, but block them via this latch
+ * 3) Represent the initial model
+ * 4) Release this latch to handle changes (blocked and those that arrive from now on)
+ */
+ private final CountDownLatch block_item_changes = new CountDownLatch(1);
+
+ /** Map from alarm tree path to view's TreeItem */
+ private final ConcurrentHashMap>> path2view = new ConcurrentHashMap<>();
+
+ /** Items to update, ordered by time of original update request
+ *
+ * SYNC on access
+ */
+ private final Set>> items_to_update = new LinkedHashSet<>();
+
+ /** Throttle [5Hz] used for updates of existing items */
+ private final UpdateThrottle throttle = new UpdateThrottle(200, TimeUnit.MILLISECONDS, this::performUpdates);
+
+ /** Cursor change doesn't work on Mac, so add indicator to toolbar */
+ private final Label changing = new Label("Loading...");
+
+ /** Is change indicator shown, and future been submitted to clear it? */
+ private final AtomicReference> ongoing_change = new AtomicReference<>();
+
+ /** Clear the change indicator */
+ private final Runnable clear_change_indicator = () ->
+ Platform.runLater(() ->
+ {
+ logger.log(Level.INFO, "Alarm tree changes end");
+ ongoing_change.set(null);
+ setCursor(null);
+ final ObservableList items = getToolbar().getItems();
+ items.remove(changing);
+ });
+
+ // Javadoc for TreeItem shows example for overriding isLeaf() and getChildren()
+ // to dynamically create TreeItem as TreeView requests information.
+ //
+ // The alarm tree, however, keeps changing, and needs to locate the TreeItem
+ // for the changed AlarmTreeItem.
+ // Added code for checking if a TreeItem has been created, yet,
+ // can only make things slower,
+ // and the overall performance should not degrade when user opens more and more
+ // sections of the overall tree.
+ // --> Create the complete TreeItems ASAP and then keep updating to get
+ // constant performance?
+
+ /** @param model Model to represent. Must not be running, yet */
+ public AlarmTreeConfigView(final AlarmClient model) {
+ this(model, null);
+ }
+
+ /**
+ * @param model Model to represent. Must not be running, yet
+ * @param itemName item name that may be expanded or given focus
+ */
+ public AlarmTreeConfigView(final AlarmClient model, String itemName)
+ {
+ changing.setTextFill(Color.WHITE);
+ changing.setBackground(new Background(new BackgroundFill(Color.BLUE, CornerRadii.EMPTY, Insets.EMPTY)));
+
+ this.model = model;
+ this.itemName = itemName;
+
+ tree_config_view.setShowRoot(false);
+ tree_config_view.setCellFactory(view -> new AlarmTreeViewCell());
+ tree_config_view.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
+
+ setTop(createToolbar());
+ setCenter(tree_config_view);
+
+ if (AlarmSystem.alarm_tree_startup_ms <= 0)
+ {
+ // Original implementation:
+ // Create initial (empty) representation,
+ // register listener, then model gets started
+ block_item_changes.countDown();
+ tree_config_view.setRoot(createViewItem(model.getRoot()));
+ model.addListener(AlarmTreeConfigView.this);
+ }
+ else
+ UpdateThrottle.TIMER.schedule(this::startup, AlarmSystem.alarm_tree_startup_ms, TimeUnit.MILLISECONDS);
+
+ // Caller will start the model once we return from constructor
+ }
+
+ private void startup()
+ {
+ // Waited for model to receive the bulk of initial additions and removals...
+ Platform.runLater(() ->
+ {
+ if (! model.isRunning())
+ {
+ logger.log(Level.WARNING, model.getRoot().getName() + " was disposed while waiting for alarm tree startup");
+ return;
+ }
+ // Listen to model changes, but they're blocked,
+ // so this blocks model changes from now on
+ model.addListener(AlarmTreeConfigView.this);
+
+ // Represent model that should by now be fairly complete
+ tree_config_view.setRoot(createViewItem(model.getRoot()));
+
+ // expand tree item if is matches item name
+ if (tree_config_view.getRoot() != null && itemName != null) {
+ for (TreeItem treeItem : tree_config_view.getRoot().getChildren()) {
+ if (((AlarmTreeItem) treeItem.getValue()).getName().equals(itemName)) {
+ expandAlarms(treeItem);
+ break;
+ }
+ }
+ }
+
+ // Set change indicator so that it clears when there are no more changes
+ indicateChange();
+ showServerState(model.isServerAlive());
+
+ // Un-block to handle changes from now on
+ block_item_changes.countDown();
+ });
+ }
+
+ private ToolBar createToolbar()
+ {
+ final Button collapse = new Button("",
+ ImageCache.getImageView(AlarmUI.class, "/icons/collapse.png"));
+ collapse.setTooltip(new Tooltip("Collapse alarm tree"));
+ collapse.setOnAction(event ->
+ {
+ for (TreeItem> sub : tree_config_view.getRoot().getChildren())
+ sub.setExpanded(false);
+ });
+
+ final Button show_alarms = new Button("",
+ ImageCache.getImageView(AlarmUI.class, "/icons/expand_alarms.png"));
+ show_alarms.setTooltip(new Tooltip("Expand alarm tree to show active alarms"));
+ show_alarms.setOnAction(event -> expandAlarms(tree_config_view.getRoot()));
+
+ final Button show_disabled = new Button("",
+ ImageCache.getImageView(AlarmUI.class, "/icons/expand_disabled.png"));
+ show_disabled.setTooltip(new Tooltip("Expand alarm tree to show disabled PVs"));
+ show_disabled.setOnAction(event -> expandDisabledPVs(tree_config_view.getRoot()));
+
+ return new ToolBar(no_server, changing, ToolbarHelper.createSpring(), collapse, show_alarms, show_disabled);
+ }
+
+ ToolBar getToolbar()
+ {
+ return (ToolBar) getTop();
+ }
+
+ private void expandAlarms(final TreeItem> node)
+ {
+ if (node.isLeaf())
+ return;
+
+ // Always expand the root, which itself is not visible,
+ // but this will show all the top-level elements.
+ // In addition, expand those items which are in active alarm.
+ final boolean expand = node.getValue().getState().severity.isActive() ||
+ node == tree_config_view.getRoot();
+ node.setExpanded(expand);
+ for (TreeItem> sub : node.getChildren())
+ expandAlarms(sub);
+ }
+
+ /** @param node Subtree node where to expand disabled PVs
+ * @return Does subtree contain disabled PVs?
+ */
+ private boolean expandDisabledPVs(final TreeItem> node)
+ {
+ if (node != null && (node.getValue() instanceof AlarmClientLeaf))
+ {
+ AlarmClientLeaf pv = (AlarmClientLeaf) node.getValue();
+ if (! pv.isEnabled())
+ return true;
+ }
+
+ // Always expand the root, which itself is not visible,
+ // but this will show all the top-level elements.
+ // In addition, expand those items which contain disabled PV.
+ boolean expand = node == tree_config_view.getRoot();
+ for (TreeItem> sub : node.getChildren())
+ if (expandDisabledPVs(sub))
+ expand = true;
+ node.setExpanded(expand);
+ return expand;
+ }
+
+ private TreeItem> createViewItem(final AlarmTreeItem> model_item)
+ {
+ // Create view item for model item itself
+ final TreeItem> view_item = new TreeItem<>(model_item);
+ final TreeItem> previous = path2view.put(model_item.getPathName(), view_item);
+ if (previous != null)
+ throw new IllegalStateException("Found existing view item for " + model_item.getPathName());
+
+ // Create view items for model item's children
+ for (final AlarmTreeItem> model_child : model_item.getChildren())
+ view_item.getChildren().add(createViewItem(model_child));
+
+ return view_item;
+ }
+
+ /** Called when an item is added/removed to tell user
+ * that there are changes to the tree structure,
+ * may not make sense to interact with the tree right now.
+ *
+ *
Resets on its own after 1 second without changes.
+ */
+ private void indicateChange()
+ {
+ final ScheduledFuture> previous = ongoing_change.getAndSet(UpdateThrottle.TIMER.schedule(clear_change_indicator, 1, TimeUnit.SECONDS));
+ if (previous == null)
+ {
+ logger.log(Level.INFO, "Alarm tree changes start");
+ setCursor(Cursor.WAIT);
+ final ObservableList items = getToolbar().getItems();
+ if (! items.contains(changing))
+ items.add(1, changing);
+ }
+ else
+ previous.cancel(false);
+ }
+
+ /** @param alive Have we seen server messages? */
+ private void showServerState(final boolean alive)
+ {
+ final ObservableList items = getToolbar().getItems();
+ items.remove(no_server);
+ if (! alive)
+ // Place left of spring, collapse, expand_alarms,
+ // i.e. right of potential AlarmConfigSelector
+ items.add(items.size()-3, no_server);
+ }
+
+ // AlarmClientModelListener
+ @Override
+ public void serverStateChanged(final boolean alive)
+ {
+ Platform.runLater(() -> showServerState(alive));
+ }
+
+ // AlarmClientModelListener
+ @Override
+ public void serverModeChanged(final boolean maintenance_mode)
+ {
+ // NOP
+ }
+
+ // AlarmClientModelListener
+ @Override
+ public void serverDisableNotifyChanged(final boolean disable_notify)
+ {
+ // NOP
+ }
+
+ /** Block until changes to items should be shown */
+ private void blockItemChanges()
+ {
+ try
+ {
+ block_item_changes.await();
+ }
+ catch (InterruptedException ex)
+ {
+ logger.log(Level.WARNING, "Blocker for item changes got interrupted", ex);
+ }
+ }
+
+ // AlarmClientModelListener
+ @Override
+ public void itemAdded(final AlarmTreeItem> item)
+ {
+ blockItemChanges();
+ // System.out.println(Thread.currentThread() + " Add " + item.getPathName());
+
+ // Parent must already exist
+ final AlarmTreeItem model_parent = item.getParent();
+ final TreeItem> view_parent = path2view.get(model_parent.getPathName());
+
+ if (view_parent == null)
+ {
+ dumpTree(tree_config_view.getRoot());
+ throw new IllegalStateException("Missing parent view item for " + item.getPathName());
+ }
+ // Create view item ASAP so that following updates will find it..
+ final TreeItem> view_item = createViewItem(item);
+
+ // .. but defer showing it on screen to UI thread
+ final CountDownLatch done = new CountDownLatch(1);
+ Platform.runLater(() ->
+ {
+ indicateChange();
+ // Keep sorted by inserting at appropriate index
+ final List>> items = view_parent.getChildren();
+ final int index = Collections.binarySearch(items, view_item,
+ (a, b) -> CompareNatural.compareTo(a.getValue().getName(),
+ b.getValue().getName()));
+ if (index < 0)
+ items.add(-index-1, view_item);
+ else
+ items.add(index, view_item);
+ done.countDown();
+ });
+
+ // Waiting on the UI thread throttles the model's updates
+ // to a rate that the UI can handle.
+ // The result is a slower startup when loading the model,
+ // but keeping the UI responsive
+ try
+ {
+ done.await();
+ }
+ catch (final InterruptedException ex)
+ {
+ logger.log(Level.WARNING, "Alarm tree update error for added item " + item.getPathName(), ex);
+ }
+ }
+
+ // AlarmClientModelListener
+ @Override
+ public void itemRemoved(final AlarmTreeItem> item)
+ {
+ blockItemChanges();
+ // System.out.println(Thread.currentThread() + " Removed " + item.getPathName());
+
+ // Remove item and all sub-items from model2ui
+ final TreeItem> view_item = removeViewItems(item);
+ if (view_item == null)
+ throw new IllegalStateException("No view item for " + item.getPathName());
+
+ // Remove the corresponding view
+ final CountDownLatch done = new CountDownLatch(1);
+ Platform.runLater(() ->
+ {
+ indicateChange();
+ // Can only locate the parent view item on UI thread,
+ // because item might just have been created by itemAdded() event
+ // and won't be on the screen until UI thread runs.
+ final TreeItem> view_parent = view_item.getParent();
+ if (view_parent == null)
+ throw new IllegalStateException("No parent in view for " + item.getPathName());
+ view_parent.getChildren().remove(view_item);
+ done.countDown();
+ });
+
+ // Waiting on the UI thread throttles the model's updates
+ // to a rate that the UI can handle.
+ // The result is a slower startup when loading the model,
+ // but keeping the UI responsive
+ try
+ {
+ done.await();
+ }
+ catch (final InterruptedException ex)
+ {
+ logger.log(Level.WARNING, "Alarm tree update error for removed item " + item.getPathName(), ex);
+ }
+ }
+
+ /** @param item Item for which the TreeItem should be removed from path2view. Recurses to all child entries.
+ * @return TreeItem for 'item'
+ */
+ private TreeItem> removeViewItems(final AlarmTreeItem> item)
+ {
+ final TreeItem> view_item = path2view.remove(item.getPathName());
+
+ for (final AlarmTreeItem> child : item.getChildren())
+ removeViewItems(child);
+
+ return view_item;
+ }
+
+ // AlarmClientModelListener
+ @Override
+ public void itemUpdated(final AlarmTreeItem> item)
+ {
+ blockItemChanges();
+ // System.out.println(Thread.currentThread() + " Updated " + item.getPathName());
+ final TreeItem> view_item = path2view.get(item.getPathName());
+ if (view_item == null)
+ {
+ System.out.println("Unknown view for " + item.getPathName());
+ path2view.keySet().stream().forEach(System.out::println);
+ throw new IllegalStateException("No view item for " + item.getPathName());
+ }
+
+ // UI update of existing item, i.e.
+ // Platform.runLater(() -> TreeHelper.triggerTreeItemRefresh(view_item));
+ // is throttled.
+ // If several items update, they're all redrawn in one Platform call,
+ // and rapid updates of the same item are merged into just one final update
+ synchronized (items_to_update)
+ {
+ items_to_update.add(view_item);
+ }
+ throttle.trigger();
+ }
+
+ /** Called by throttle to perform accumulated updates */
+ @SuppressWarnings("unchecked")
+ private void performUpdates()
+ {
+ final TreeItem>[] view_items;
+ synchronized (items_to_update)
+ {
+ // Creating a direct copy, i.e. another new LinkedHashSet<>(items_to_update),
+ // would be expensive, since we only need a _list_ of what's to update.
+ // Could use type-safe
+ // new ArrayList>>(items_to_update)
+ // but that calls toArray() internally, so doing that directly
+ view_items = items_to_update.toArray(new TreeItem[items_to_update.size()]);
+ items_to_update.clear();
+ }
+
+ // Remember selection
+ final ObservableList>> updatedSelectedItems =
+ FXCollections.observableArrayList(tree_config_view.getSelectionModel().getSelectedItems());
+
+ // How to update alarm tree cells when data changed?
+ // `setValue()` with a truly new value (not 'equal') should suffice,
+ // but there are two problems:
+ // Since we're currently using the alarm tree model item as a value,
+ // the value as seen by the TreeView remains the same.
+ // We could use a model item wrapper class as the cell value
+ // and replace it (while still holding the same model item!)
+ // for the TreeView to see a different wrapper value, but
+ // as shown in org.phoebus.applications.alarm.TreeItemUpdateDemo,
+ // replacing a tree cell value fails to trigger refreshes
+ // for certain hidden items.
+ // Only replacing the TreeItem gives reliable refreshes.
+ for (final TreeItem> view_item : view_items)
+ // Top-level item has no parent, and is not visible, so we keep it
+ if (view_item.getParent() != null)
+ {
+ // Locate item in tree parent
+ final TreeItem> parent = view_item.getParent();
+ final int index = parent.getChildren().indexOf(view_item);
+
+ // Create new TreeItem for that value
+ final AlarmTreeItem> value = view_item.getValue();
+ final TreeItem> update = new TreeItem<>(value);
+ if (updatedSelectedItems.contains(view_item)) {
+ updatedSelectedItems.remove(view_item);
+ updatedSelectedItems.add(update);
+ }
+ // Move child links to new item
+ final ArrayList>> children = new ArrayList<>(view_item.getChildren());
+ view_item.getChildren().clear();
+ update.getChildren().addAll(children);
+ update.setExpanded(view_item.isExpanded());
+
+ path2view.put(value.getPathName(), update);
+ parent.getChildren().set(index, update);
+ }
+
+ tree_config_view.getSelectionModel().clearSelection();
+ updatedSelectedItems.forEach(item -> tree_config_view.getSelectionModel().select(item));
+
+ }
+
+ private void dumpTree(TreeItem> item)
+ {
+ final ObservableList>> children = item.getChildren();
+ System.out.printf("item: %s , has %d children.\n", item.getValue().getName(), children.size());
+ for (final TreeItem> child : children)
+ {
+ System.out.println(child.getValue().getName());
+ dumpTree(child);
+ }
+ }
+
+ /**
+ * Allows external classes to attach a selection listener to the tree view.
+ * @param listener ChangeListener for selected TreeItem
+ */
+ public void addTreeSelectionListener(ChangeListener super TreeItem>> listener) {
+ tree_config_view.getSelectionModel().selectedItemProperty().addListener(listener);
+ }
+
+ /**
+ * Allows external classes to remove a selection listener from the tree view.
+ * @param listener ChangeListener for selected TreeItem
+ */
+ public void removeTreeSelectionListener(ChangeListener super TreeItem>> listener) {
+ tree_config_view.getSelectionModel().selectedItemProperty().removeListener(listener);
+ }
+}
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeView.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeView.java
index 83a3efd6aa..179b971ce0 100644
--- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeView.java
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeView.java
@@ -19,10 +19,12 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.stream.Collectors;
+import javafx.collections.FXCollections;
import org.phoebus.applications.alarm.AlarmSystem;
import org.phoebus.applications.alarm.client.AlarmClient;
import org.phoebus.applications.alarm.client.AlarmClientLeaf;
@@ -529,6 +531,10 @@ private void performUpdates()
items_to_update.clear();
}
+ // Remember selection
+ final ObservableList>> updatedSelectedItems =
+ FXCollections.observableArrayList(tree_view.getSelectionModel().getSelectedItems());
+
// How to update alarm tree cells when data changed?
// `setValue()` with a truly new value (not 'equal') should suffice,
// but there are two problems:
@@ -552,6 +558,10 @@ private void performUpdates()
// Create new TreeItem for that value
final AlarmTreeItem> value = view_item.getValue();
final TreeItem> update = new TreeItem<>(value);
+ if (updatedSelectedItems.contains(view_item)) {
+ updatedSelectedItems.remove(view_item);
+ updatedSelectedItems.add(update);
+ }
// Move child links to new item
final ArrayList>> children = new ArrayList<>(view_item.getChildren());
view_item.getChildren().clear();
@@ -561,6 +571,9 @@ private void performUpdates()
path2view.put(value.getPathName(), update);
parent.getChildren().set(index, update);
}
+ // Restore selection
+ tree_view.getSelectionModel().clearSelection();
+ updatedSelectedItems.forEach(item -> tree_view.getSelectionModel().select(item));
}
/** Context menu, details depend on selected items */
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ContextMenuAddComponentPVs.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ContextMenuAddComponentPVs.java
new file mode 100644
index 0000000000..ee49043ec0
--- /dev/null
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ContextMenuAddComponentPVs.java
@@ -0,0 +1,317 @@
+package org.phoebus.applications.alarm.ui.tree;
+
+import javafx.beans.value.ChangeListener;
+import javafx.geometry.Insets;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.Dialog;
+import javafx.scene.control.Label;
+import javafx.scene.control.TextArea;
+import javafx.scene.control.TextField;
+import javafx.scene.control.TreeItem;
+import javafx.scene.image.Image;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.VBox;
+import org.phoebus.applications.alarm.AlarmSystem;
+import org.phoebus.applications.alarm.client.AlarmClient;
+import org.phoebus.applications.alarm.model.AlarmTreeItem;
+import org.phoebus.applications.alarm.ui.AlarmConfigSelector;
+import org.phoebus.core.types.ProcessVariable;
+import org.phoebus.framework.selection.Selection;
+import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog;
+import org.phoebus.ui.javafx.ImageCache;
+import org.phoebus.ui.spi.ContextMenuEntry;
+
+import java.util.List;
+import java.util.logging.Level;
+import java.util.stream.Collectors;
+
+import static org.phoebus.applications.alarm.AlarmSystemConstants.logger;
+
+public class ContextMenuAddComponentPVs implements ContextMenuEntry {
+
+ private static final Class> supportedTypes = ProcessVariable.class;
+
+ @Override
+ public boolean isEnabled() {
+ return AlarmSystem.enable_add_to_alarm_context_menu;
+ }
+
+ @Override
+ public String getName() {
+ return "Add PVs to Alarm System";
+ }
+
+ @Override
+ public Image getIcon() {
+ return ImageCache.getImageView(ImageCache.class, "/icons/add.png").getImage();
+ }
+
+ @Override
+ public Class> getSupportedType() {
+ return supportedTypes;
+ }
+
+ @Override
+ public void call(Selection selection) throws Exception {
+ List pvs = selection.getSelections();
+
+ AddComponentPVsDialog addDialog = new AddComponentPVsDialog(AlarmSystem.server,
+ AlarmSystem.config_name,
+ AlarmSystem.kafka_properties,
+ pvs.stream().map(ProcessVariable::getName).collect(Collectors.toList()),
+ null);
+
+ addDialog.showAndWait();
+ }
+
+ /**
+ * Dialog for adding component PVs to an alarm configuration
+ */
+ private static class AddComponentPVsDialog extends Dialog {
+ private final TextArea pvNamesInput;
+ private final TextField pathInput;
+ private final VBox content;
+ private final Label treeLabel;
+
+ private AlarmClient alarmClient;
+ private AlarmTreeConfigView configView;
+ private ChangeListener>> selectionListener;
+ private final String server;
+ private final String kafka_properties;
+
+ /**
+ * Constructor for AddComponentPVsDialog
+ *
+ * @param server The alarm server
+ * @param config_name The alarm configuration name
+ * @param kafka_properties Kafka properties for the AlarmClient
+ * @param pvNames Initial list of PV names to pre-populate
+ * @param currentPath The current path (initial value for text input)
+ */
+
+ public AddComponentPVsDialog(String server, String config_name, String kafka_properties, List pvNames, String currentPath) {
+ this.server = server;
+ this.kafka_properties = kafka_properties;
+
+ setTitle("Add PVs to Alarm Configuration");
+ setHeaderText("Select PVs and destination path");
+ setResizable(true);
+
+ // Create content
+ content = new VBox(10);
+ content.setPadding(new Insets(15));
+
+ // PV Names input section
+ Label pvLabel = new Label("PV Names (semicolon-separated):");
+ pvNamesInput = new TextArea();
+ pvNamesInput.setPromptText("Enter PV names separated by semicolons (;)");
+ pvNamesInput.setPrefRowCount(3);
+ pvNamesInput.setWrapText(true);
+
+ // Pre-populate with initial PVs if provided
+ if (pvNames != null && !pvNames.isEmpty()) {
+ pvNamesInput.setText(String.join("; ", pvNames));
+ }
+
+ // Tree label
+ treeLabel = new Label("Select destination in alarm tree:");
+
+ // Path input
+ Label pathLabel = new Label("Destination Path:");
+ pathInput = new TextField();
+ pathInput.setText(currentPath != null ? currentPath : "");
+ pathInput.setStyle("-fx-font-family: monospace;");
+ pathInput.setPromptText("Select a path from the tree above or type manually");
+ pathInput.setEditable(true);
+
+ // Add static components to layout
+ content.getChildren().addAll(
+ pvLabel,
+ pvNamesInput,
+ treeLabel
+ );
+
+ // Create initial tree view
+ createTreeView(config_name);
+
+ // Add path input section
+ content.getChildren().addAll(
+ pathLabel,
+ pathInput
+ );
+
+ // Make tree view grow to fill available space
+ VBox.setVgrow(configView, Priority.ALWAYS);
+
+ getDialogPane().setContent(content);
+ getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
+ getDialogPane().setPrefSize(600, 700);
+
+ // Validate and add PVs when OK is clicked
+ getDialogPane().lookupButton(ButtonType.OK).addEventFilter(javafx.event.ActionEvent.ACTION, event -> {
+ // Validate path
+ String path = pathInput.getText().trim();
+ if (path.isEmpty()) {
+ event.consume(); // Prevent dialog from closing
+ ExceptionDetailsErrorDialog.openError("Invalid Path",
+ "Destination path cannot be empty.\nPlease enter or select a valid path.",
+ null);
+ return;
+ }
+
+ // Validate that path exists in the alarm tree
+ if (!AlarmTreeHelper.validateNewPath(path, alarmClient.getRoot())) {
+ event.consume(); // Prevent dialog from closing
+ ExceptionDetailsErrorDialog.openError("Invalid Path",
+ "The path '" + path + "' is not valid in the alarm tree.\n\n" +
+ "Please select a valid path from the tree or enter a valid path manually.",
+ null);
+ return;
+ }
+
+ // Get PV names
+ List pvNamesToAdd = getPVNames();
+ if (pvNamesToAdd.isEmpty()) {
+ event.consume(); // Prevent dialog from closing
+ ExceptionDetailsErrorDialog.openError("No PV Names",
+ "No PV names were entered.\n\n" +
+ "Please enter one or more PV names separated by semicolons (;).",
+ null);
+ return;
+ }
+
+ // Try to add PVs, tracking successes and failures
+ List successfulPVs = new java.util.ArrayList<>();
+ List failedPVs = new java.util.ArrayList<>();
+ Exception lastException = null;
+
+ for (String pvName : pvNamesToAdd) {
+ try {
+ alarmClient.addPV(path, pvName);
+ successfulPVs.add(pvName);
+ } catch (Exception e) {
+ failedPVs.add(pvName);
+ lastException = e;
+ logger.log(Level.WARNING, "Failed to add PV '" + pvName + "' to " + path, e);
+ }
+ }
+
+ // Report results
+ if (!failedPVs.isEmpty()) {
+ event.consume(); // Prevent dialog from closing
+ String message = String.format(
+ "Failed to add %d of %d PV(s) to path: %s\n\n" +
+ "Successful: %s\n" +
+ "Failed: %s\n\n" +
+ "Last error: %s",
+ failedPVs.size(),
+ pvNamesToAdd.size(),
+ path,
+ successfulPVs.isEmpty() ? "None" : String.join(", ", successfulPVs),
+ String.join(", ", failedPVs),
+ lastException != null ? lastException.getMessage() : "Unknown"
+ );
+ logger.log(Level.WARNING, message);
+ ExceptionDetailsErrorDialog.openError("Add Component PVs Failed", message, lastException);
+ } else {
+ logger.log(Level.INFO, "Successfully added " + successfulPVs.size() + " PV(s) to " + path);
+ }
+ });
+ }
+
+ private void createTreeView(String config_name) {
+ // Create new AlarmClient
+ alarmClient = new AlarmClient(server, config_name, kafka_properties);
+
+ // Create new AlarmTreeConfigView
+ configView = new AlarmTreeConfigView(alarmClient);
+ configView.setPrefHeight(300);
+ configView.setPrefWidth(500);
+
+ // Add config selector if multiple configs are available
+ if (AlarmSystem.config_names.length > 0) {
+ final AlarmConfigSelector configs = new AlarmConfigSelector(config_name, this::changeConfig);
+ configView.getToolbar().getItems().add(0, configs);
+ }
+
+ // Start the client
+ alarmClient.start();
+
+ // Create selection listener
+ selectionListener = (obs, oldVal, newVal) -> {
+ if (newVal != null && newVal.getValue() != null) {
+ String selectedPath = newVal.getValue().getPathName();
+ if (selectedPath != null && !selectedPath.isEmpty()) {
+ // Only update if path input is not focused
+ if (!pathInput.isFocused()) {
+ pathInput.setText(selectedPath);
+ }
+ }
+ }
+ };
+ configView.addTreeSelectionListener(selectionListener);
+
+ // Remove the listener and dispose AlarmClient when the dialog is closed
+ this.setOnHidden(e -> {
+ configView.removeTreeSelectionListener(selectionListener);
+ dispose();
+ });
+
+ // Find the position where tree view should be (after treeLabel)
+ int treeIndex = content.getChildren().indexOf(treeLabel) + 1;
+
+ // Remove old tree view if present (when switching configs)
+ if (treeIndex < content.getChildren().size()) {
+ if (content.getChildren().get(treeIndex) instanceof AlarmTreeConfigView) {
+ content.getChildren().remove(treeIndex);
+ }
+ }
+
+ // Add new tree view at the correct position
+ content.getChildren().add(treeIndex, configView);
+ VBox.setVgrow(configView, Priority.ALWAYS);
+ }
+
+ private void changeConfig(String new_config_name) {
+ // Dispose existing client
+ dispose();
+
+ try {
+ // Create new tree view with new configuration
+ createTreeView(new_config_name);
+ } catch (Exception ex) {
+ logger.log(Level.WARNING, "Cannot switch alarm tree to " + new_config_name, ex);
+ ExceptionDetailsErrorDialog.openError("Configuration Switch Failed",
+ "Failed to switch to configuration: " + new_config_name,
+ ex);
+ }
+ }
+
+ private void dispose()
+ {
+ if (alarmClient != null)
+ {
+ alarmClient.shutdown();
+ alarmClient = null;
+ }
+ }
+ /**
+ * Get the list of PV names entered by the user
+ *
+ * @return List of PV names (trimmed and non-empty)
+ */
+ private List getPVNames() {
+ String text = pvNamesInput.getText();
+ if (text == null || text.trim().isEmpty()) {
+ return List.of();
+ }
+
+ // Split by semicolon, trim each entry, and filter out empty strings
+ return List.of(text.split(";"))
+ .stream()
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .collect(Collectors.toList());
+ }
+ }
+}
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/DuplicatePVAction.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/DuplicatePVAction.java
index 5fff9d078a..d93214df08 100644
--- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/DuplicatePVAction.java
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/DuplicatePVAction.java
@@ -63,9 +63,9 @@ public DuplicatePVAction(final Node node, final AlarmClient model, final AlarmCl
template.setActions(original.getActions());
// Request adding new PV
- final String new_path = AlarmTreePath.makePath(original.getParent().getPathName(), new_name);
JobManager.schedule(getText(), monitor -> {
try {
+ final String new_path = AlarmTreePath.makePath(original.getParent().getPathName(), new_name);
model.sendItemConfigurationUpdate(new_path, template);
} catch (Exception e) {
Logger.getLogger(DuplicatePVAction.class.getName()).log(Level.WARNING, "Failed to send item configuration", e);
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/MoveTreeItemAction.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/MoveTreeItemAction.java
index 69f99a68c8..93ac6d881b 100644
--- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/MoveTreeItemAction.java
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/MoveTreeItemAction.java
@@ -10,6 +10,7 @@
import org.phoebus.applications.alarm.AlarmSystem;
import org.phoebus.applications.alarm.client.AlarmClient;
import org.phoebus.applications.alarm.model.AlarmTreeItem;
+import org.phoebus.applications.alarm.model.AlarmTreePath;
import org.phoebus.applications.alarm.ui.Messages;
import org.phoebus.framework.jobs.JobManager;
import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog;
@@ -39,25 +40,39 @@ public MoveTreeItemAction(TreeView> node,
setOnAction(event ->
{
- //Prompt for new name
-
- String prompt = "Enter new path for item";
-
+ // Show dialog with tree visualization for path selection
String path = item.getPathName();
while (true)
{
- path = AlarmTreeHelper.prompt(getText(), prompt, path, node);
- if (path == null)
+ AlarmTreeConfigDialog dialog = new AlarmTreeConfigDialog(
+ model,
+ path,
+ getText(),
+ "Select new path for item"
+ );
+ var result = dialog.getPath();
+ if (result.isEmpty())
return;
- if (AlarmTreeHelper.validateNewPath(path, node.getRoot().getValue()) )
+ path = result.get();
+ if (AlarmTreeHelper.validateNewPath(path, node.getRoot().getValue()))
break;
- prompt = "Invalid path. Try again or cancel";
+
+ // Show error dialog and retry
+ ExceptionDetailsErrorDialog.openError("Invalid Path",
+ "Invalid path. Please try again.",
+ null);
}
+ // The move is done by copying the node from the old path to the new path,
+ // and then deleting the item at the old path.
+ // Without this check, entering the old path would result in just deleting the item.
+ if (AlarmTreePath.pathsAreEquivalent(path, item.getPathName())) {
+ return;
+ }
+
// Tree view keeps the selection indices, which will point to wrong content
// after those items have been removed.
- if (node instanceof TreeView>)
- ((TreeView>) node).getSelectionModel().clearSelection();
+ node.getSelectionModel().clearSelection();
final String new_path = path;
// On a background thread, send the item configuration updates for the item to be moved and all its children.
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/RenameTreeItemAction.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/RenameTreeItemAction.java
index cbca4c91a1..40e5f13191 100644
--- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/RenameTreeItemAction.java
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/RenameTreeItemAction.java
@@ -57,8 +57,8 @@ public RenameTreeItemAction(final TreeView> node,
final AlarmTreeItem parent = item.getParent();
// Remove the item and all its children.
// Add the new item, and then rebuild all its children.
- final String new_path = AlarmTreePath.makePath(parent.getPathName(), new_name);
try {
+ final String new_path = AlarmTreePath.makePath(parent.getPathName(), new_name);
model.sendItemConfigurationUpdate(new_path, item);
AlarmTreeHelper.rebuildTree(model, item, new_path);
model.removeComponent(item);
@@ -87,8 +87,8 @@ public RenameTreeItemAction(final TreeView> node,
JobManager.schedule(getText(), monitor ->
{
final AlarmTreeItem parent = item.getParent();
- final String new_path = AlarmTreePath.makePath(parent.getPathName(), new_name);
try {
+ final String new_path = AlarmTreePath.makePath(parent.getPathName(), new_name);
model.sendItemConfigurationUpdate(new_path, item);
model.removeComponent(item);
} catch (Exception e) {
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/TitleDetailDelayTable.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/TitleDetailDelayTable.java
index c1ae0a051a..118786ec6b 100644
--- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/TitleDetailDelayTable.java
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/TitleDetailDelayTable.java
@@ -9,14 +9,13 @@
import java.util.ArrayList;
import java.util.List;
-import java.util.concurrent.TimeUnit;
+import java.util.Objects;
import org.phoebus.applications.alarm.model.TitleDetailDelay;
import org.phoebus.applications.alarm.ui.AlarmUI;
import org.phoebus.ui.dialog.DialogHelper;
import org.phoebus.ui.dialog.MultiLineInputDialog;
import org.phoebus.ui.javafx.ImageCache;
-import org.phoebus.ui.javafx.UpdateThrottle;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
@@ -35,7 +34,6 @@
import javafx.scene.control.TableView;
import javafx.scene.control.Tooltip;
import javafx.scene.control.cell.ComboBoxTableCell;
-import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.util.converter.DefaultStringConverter;
@@ -104,8 +102,49 @@ class DelayTableCell extends TableCell
public DelayTableCell()
{
this.spinner = new Spinner<>(0, 10000, 1);
- spinner.setEditable(true);
- this.spinner.valueProperty().addListener((observable, oldValue, newValue) -> commitEdit(newValue));
+ this.spinner.setEditable(true);
+
+ // disable focus on buttons
+ spinner.lookupAll(".increment-arrow-button, .decrement-arrow-button")
+ .forEach(node -> node.setFocusTraversable(false));
+
+ this.spinner.valueProperty().addListener((obs, oldValue, newValue) -> {
+ if (isEditing()) {
+ commitEdit(newValue);
+ }
+ });
+
+ // validate when loosing focus
+ spinner.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> {
+ if (!isNowFocused) {
+ Integer currentValue = spinner.getValue();
+ Integer oldValue = getItem();
+
+ if (Objects.equals(currentValue, oldValue)) {
+ cancelEdit();
+ return;
+ }
+
+ // if not currently editing, force table to enter edit mode first
+ if (!isEditing()) {
+ TableView tv = getTableView();
+ if (tv != null) {
+ Platform.runLater(() -> {
+ tv.getSelectionModel().clearAndSelect(getIndex());
+ tv.edit(getIndex(), getTableColumn());
+ // commit after we've asked the table to start editing
+ commitEdit(currentValue);
+ });
+ } else {
+ // fallback: commit anyway
+ commitEdit(currentValue);
+ }
+ } else {
+ // normal case
+ commitEdit(currentValue);
+ }
+ }
+ });
}
@Override
@@ -135,6 +174,13 @@ public void updateItem(Integer item, boolean empty)
this.spinner.getValueFactory().setValue(item);
setGraphic(spinner);
+ // force focus on the textedit not buttons
+ Platform.runLater(() -> {
+ if (isEditing()) {
+ spinner.getEditor().requestFocus();
+ spinner.getEditor().end();
+ }
+ });
}
}
@@ -147,20 +193,19 @@ private void createTable()
TableColumn col = new TableColumn<>("Title");
col.setCellValueFactory(cell -> new SimpleStringProperty(cell.getValue().title));
- col.setCellFactory(column -> new TextFieldTableCell<>(new DefaultStringConverter()));
+ col.setCellFactory(ValidatingTextFieldTableCell.forTableColumn(new DefaultStringConverter()));
col.setOnEditCommit(event ->
{
final int row = event.getTablePosition().getRow();
items.set(row, new TitleDetailDelay(event.getNewValue(), items.get(row).detail, items.get(row).delay));
// Trigger editing the detail
- UpdateThrottle.TIMER.schedule(() ->
- Platform.runLater(() ->
- {
- table.getSelectionModel().clearAndSelect(row);
- table.edit(row, table.getColumns().get(1));
- }),
- 200, TimeUnit.MILLISECONDS);
+ Platform.runLater(() -> {
+ TableColumn detailCol = table.getColumns().get(1);
+ TableColumn optionCol = detailCol.getColumns().get(0);
+ table.getSelectionModel().clearAndSelect(row);
+ table.edit(row, optionCol);
+ });
});
col.setSortable(false);
table.getColumns().add(col);
@@ -182,10 +227,12 @@ private void createTable()
items.set(row, newTitleDetailDelay);
// Trigger editing the delay.
if (newTitleDetailDelay.hasDelay())
- UpdateThrottle.TIMER.schedule(() -> Platform.runLater(() -> {
- table.getSelectionModel().clearAndSelect(row);
- table.edit(row, table.getColumns().get(2));
- }), 200, TimeUnit.MILLISECONDS);
+ Platform.runLater(() -> {
+ TableColumn detailCol = table.getColumns().get(1);
+ TableColumn infoCol = detailCol.getColumns().get(1);
+ table.getSelectionModel().clearAndSelect(row);
+ table.edit(row, infoCol);
+ });
});
tmpOptionCol.setEditable(true);
col.getColumns().add(tmpOptionCol);
@@ -193,7 +240,7 @@ private void createTable()
// Use a textfield to set info for detail
TableColumn infoCol = new TableColumn<>("Info");
infoCol.setCellValueFactory(cell -> new SimpleStringProperty(getInfoFromDetail(cell.getValue())));
- infoCol.setCellFactory(column -> new TextFieldTableCell<>(new DefaultStringConverter()));
+ infoCol.setCellFactory(ValidatingTextFieldTableCell.forTableColumn(new DefaultStringConverter()));
infoCol.setOnEditCommit(event -> {
final int row = event.getTablePosition().getRow();
TitleDetailDelay tmpT = items.get(row);
@@ -202,10 +249,10 @@ private void createTable()
items.set(row, newTitleDetailDelay);
// Trigger editing the delay.
if (newTitleDetailDelay.hasDelay())
- UpdateThrottle.TIMER.schedule(() -> Platform.runLater(() -> {
- table.getSelectionModel().clearAndSelect(row);
- table.edit(row, table.getColumns().get(2));
- }), 200, TimeUnit.MILLISECONDS);
+ Platform.runLater(() -> {
+ table.getSelectionModel().clearAndSelect(row);
+ table.edit(row, table.getColumns().get(2));
+ });
});
infoCol.setSortable(false);
col.getColumns().add(infoCol);
@@ -306,14 +353,12 @@ private void createButtons()
items.add(new TitleDetailDelay("", "", 0));
// Trigger editing the title of new item
- UpdateThrottle.TIMER.schedule(() ->
- Platform.runLater(() ->
- {
- final int row = items.size()-1;
- table.getSelectionModel().clearAndSelect(row);
- table.edit(row, table.getColumns().get(0));
- }),
- 200, TimeUnit.MILLISECONDS);
+ Platform.runLater(() ->
+ {
+ final int row = items.size()-1;
+ table.getSelectionModel().clearAndSelect(row);
+ table.edit(row, table.getColumns().get(0));
+ });
});
edit.setTooltip(new Tooltip("Edit the detail field of table item."));
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/TitleDetailTable.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/TitleDetailTable.java
index fd28257f4d..577cc20852 100644
--- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/TitleDetailTable.java
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/TitleDetailTable.java
@@ -30,7 +30,6 @@
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.Tooltip;
-import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.util.converter.DefaultStringConverter;
@@ -83,27 +82,25 @@ private void createTable()
TableColumn col = new TableColumn<>("Title");
col.setCellValueFactory(cell -> new SimpleStringProperty(cell.getValue().title));
- col.setCellFactory(column -> new TextFieldTableCell<>(new DefaultStringConverter()));
+ col.setCellFactory(ValidatingTextFieldTableCell.forTableColumn(new DefaultStringConverter()));
col.setOnEditCommit(event ->
{
final int row = event.getTablePosition().getRow();
items.set(row, new TitleDetail(event.getNewValue(), items.get(row).detail));
// Trigger editing the detail
- UpdateThrottle.TIMER.schedule(() ->
- Platform.runLater(() ->
- {
- table.getSelectionModel().clearAndSelect(row);
- table.edit(row, table.getColumns().get(1));
- }),
- 200, TimeUnit.MILLISECONDS);
+ Platform.runLater(() ->
+ {
+ table.getSelectionModel().clearAndSelect(row);
+ table.edit(row, table.getColumns().get(1));
+ });
});
col.setSortable(false);
table.getColumns().add(col);
col = new TableColumn<>("Detail");
col.setCellValueFactory(cell -> new SimpleStringProperty(cell.getValue().detail.replace("\n", "\\n")));
- col.setCellFactory(column -> new TextFieldTableCell<>(new DefaultStringConverter()));
+ col.setCellFactory(ValidatingTextFieldTableCell.forTableColumn(new DefaultStringConverter()));
col.setOnEditCommit(event ->
{
final int row = event.getTablePosition().getRow();
diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ValidatingTextFieldTableCell.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ValidatingTextFieldTableCell.java
new file mode 100644
index 0000000000..7dd3641429
--- /dev/null
+++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ValidatingTextFieldTableCell.java
@@ -0,0 +1,47 @@
+package org.phoebus.applications.alarm.ui.tree;
+
+import javafx.scene.control.TableCell;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TextField;
+import javafx.scene.control.cell.TextFieldTableCell;
+import javafx.util.Callback;
+import javafx.util.StringConverter;
+
+public class ValidatingTextFieldTableCell extends TextFieldTableCell {
+
+ private TextField textField;
+
+ public ValidatingTextFieldTableCell(StringConverter converter) {
+ super(converter);
+ }
+
+ @Override
+ public void startEdit() {
+ super.startEdit();
+ if (isEditing() && getGraphic() instanceof TextField tf) {
+ textField = tf;
+ // add listener
+ textField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> {
+ if (!isNowFocused && textField != null) {
+ T newValue = getConverter().fromString(textField.getText());
+ if (newValue.equals(getItem())) {
+ // nothing changed so cancel
+ cancelEdit();
+ } else {
+ // changed so validate
+ commitEdit(newValue);
+ }
+ }
+ });
+ }
+ }
+
+ // utility method to simplify usage in column
+ public static Callback, TableCell> forTableColumn() {
+ return forTableColumn(new javafx.util.converter.DefaultStringConverter());
+ }
+
+ public static Callback, TableCell> forTableColumn(StringConverter converter) {
+ return column -> new ValidatingTextFieldTableCell<>(converter);
+ }
+}
diff --git a/app/alarm/ui/src/main/resources/META-INF/services/org.phoebus.ui.spi.ContextMenuEntry b/app/alarm/ui/src/main/resources/META-INF/services/org.phoebus.ui.spi.ContextMenuEntry
new file mode 100644
index 0000000000..f0f8dc2a79
--- /dev/null
+++ b/app/alarm/ui/src/main/resources/META-INF/services/org.phoebus.ui.spi.ContextMenuEntry
@@ -0,0 +1 @@
+org.phoebus.applications.alarm.ui.tree.ContextMenuAddComponentPVs
diff --git a/app/databrowser-timescale/doc/index.rst b/app/databrowser-timescale/doc/index.rst
new file mode 100644
index 0000000000..2c3acde836
--- /dev/null
+++ b/app/databrowser-timescale/doc/index.rst
@@ -0,0 +1,8 @@
+Data Browser Timescale
+======================
+
+.. toctree::
+ :glob:
+ :numbered:
+
+ *
diff --git a/app/display/editor/doc/dynamic.rst b/app/display/editor/doc/dynamic.rst
index aee2b4b6e0..c4b0406632 100644
--- a/app/display/editor/doc/dynamic.rst
+++ b/app/display/editor/doc/dynamic.rst
@@ -14,6 +14,5 @@ and behaviour of Phoebus.
.. toctree::
:maxdepth: 1
- formula_functions
rules
- scripts
\ No newline at end of file
+ scripts
diff --git a/app/display/editor/doc/index.rst b/app/display/editor/doc/index.rst
index d5469b589e..f234a3d8ab 100644
--- a/app/display/editor/doc/index.rst
+++ b/app/display/editor/doc/index.rst
@@ -12,4 +12,6 @@ Display Builder is an editor and a runtime for build and running controls GUIs
datasource_connections
macros
dynamic
-
+ property_type
+ access_widget
+ access_pv_in_script
diff --git a/app/display/editor/doc/macros.rst b/app/display/editor/doc/macros.rst
index 602ea64ece..bc11b253d0 100644
--- a/app/display/editor/doc/macros.rst
+++ b/app/display/editor/doc/macros.rst
@@ -17,7 +17,7 @@ Simple Macros can be defined in several places.
1. On the command line when launching a display using the -resource option:
-.. code-block:: python
+.. code-block:: bash
java -jar /path/to/application.jar -resource file:///path/to/display.bob?MyMacroValue=Some%20Value
diff --git a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/app/DisplayEditorInstance.java b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/app/DisplayEditorInstance.java
index 1d2b607c42..a49eefbe7f 100644
--- a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/app/DisplayEditorInstance.java
+++ b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/app/DisplayEditorInstance.java
@@ -80,9 +80,15 @@ public class DisplayEditorInstance implements AppInstance
private final WidgetPropertyListener model_name_listener = (property, old_value, new_value) ->
{
- final String label = EditorUtil.isDisplayReadOnly(property.getWidget().checkDisplayModel())
- ? "[R/O] " + property.getValue()
- : "[Edit] " + property.getValue();
+ String fileName = property.getWidget().checkDisplayModel().getDisplayName();
+
+ String value = (property.getValue() == null || property.getValue().isEmpty())
+ ? fileName
+ : property.getValue();
+
+ final String label = EditorUtil.isDisplayReadOnly(property.getWidget().checkDisplayModel())
+ ? "[R/O] " + value
+ : "[Edit] " + value;
Platform.runLater(() -> dock_item.setLabel(label));
};
diff --git a/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterRepresentation.java b/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterRepresentation.java
index 4081cee78c..196ead3438 100644
--- a/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterRepresentation.java
+++ b/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterRepresentation.java
@@ -344,9 +344,11 @@ private void valueChanged(WidgetProperty> property, Object old_value, Object n
meter.setRange(observedMin - 1, observedMax + 1, false);
newObservedMinAndMaxValues = false;
linearMeterScaleHasChanged = true;
- } else if (meter.linearMeterScale.getValueRange().getLow() != 0.0 || meter.linearMeterScale.getValueRange().getHigh() != 100) {
- meter.setRange(0.0, 100.0, false);
- linearMeterScaleHasChanged = true;
+ } else if (Double.isNaN(observedMin) || Double.isNaN(observedMax)) {
+ if (meter.linearMeterScale.getValueRange().getLow() != 0.0 || meter.linearMeterScale.getValueRange().getHigh() != 100) {
+ meter.setRange(0.0, 100.0, false);
+ linearMeterScaleHasChanged = true;
+ }
}
}
}
diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/DisplayModel.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/DisplayModel.java
index fafff17280..9c4f5e9a6c 100644
--- a/app/display/model/src/main/java/org/csstudio/display/builder/model/DisplayModel.java
+++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/DisplayModel.java
@@ -173,6 +173,8 @@ public String getDisplayName()
name = getUserData(USER_DATA_INPUT_FILE);
if (name == null)
name = "";
+ else
+ name= new java.io.File(name).getName();;
}
return name;
}
diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/EmbeddedDisplayWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/EmbeddedDisplayWidget.java
index ca5dcb967e..a153fa52e5 100644
--- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/EmbeddedDisplayWidget.java
+++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/EmbeddedDisplayWidget.java
@@ -307,6 +307,13 @@ public WidgetProperty> getProperty(String name) throws IllegalArgumentExceptio
return super.getProperty(name);
}
+ @Override
+ public Macros getEffectiveMacros()
+ {
+ final Macros macros = new Macros(super.getEffectiveMacros());
+ return macros;
+ }
+
/** @return 'file' property */
public WidgetProperty propFile()
{
diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/NavigationTabsWidget.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/NavigationTabsWidget.java
index de21dfdcf0..3345241e89 100644
--- a/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/NavigationTabsWidget.java
+++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/widgets/NavigationTabsWidget.java
@@ -47,6 +47,8 @@
*/
public class NavigationTabsWidget extends VisibleWidget
{
+ private static final WidgetColor DEFAULT_SELECT_COLOR = new WidgetColor(236, 236, 236);
+ private static final WidgetColor DEFAULT_DESELECT_COLOR = new WidgetColor(200, 200, 200);
/** Widget descriptor */
public static final WidgetDescriptor WIDGET_DESCRIPTOR =
new WidgetDescriptor("navtabs", WidgetCategory.STRUCTURE,
@@ -61,10 +63,17 @@ public Widget createWidget()
}
};
- // 'state' structure that describes one state
+ // 'tab' structure that describes one tab
private static final StructuredWidgetProperty.Descriptor propTab =
new StructuredWidgetProperty.Descriptor(WidgetPropertyCategory.BEHAVIOR, "tab", "Tab");
+ // Elements of the 'tab' structure
+ private static final WidgetPropertyDescriptor propIndividualSelectedColor =
+ CommonWidgetProperties.newColorPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "selected_color", "Selected Color");
+
+ private static final WidgetPropertyDescriptor propIndividualDeselectedColor =
+ CommonWidgetProperties.newColorPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "deselected_color", "Deselected Color");
+
/** Structure for one tab item and its embedded display */
public static class TabProperty extends StructuredWidgetProperty
{
@@ -77,8 +86,10 @@ public TabProperty(final Widget widget, final int index)
Arrays.asList(propName.createProperty(widget, "Tab " + (index + 1)),
propFile.createProperty(widget, ""),
propMacros.createProperty(widget, new Macros()),
- propGroupName.createProperty(widget, "")
- ));
+ propGroupName.createProperty(widget, ""),
+ propIndividualSelectedColor.createProperty(widget, DEFAULT_SELECT_COLOR),
+ propIndividualDeselectedColor.createProperty(widget, DEFAULT_DESELECT_COLOR)
+ ));
}
/** @return Tab name */
public WidgetProperty name() { return getElement(0); }
@@ -88,6 +99,10 @@ public TabProperty(final Widget widget, final int index)
public WidgetProperty macros() { return getElement(2); }
/** @return Optional sub-group of file */
public WidgetProperty group() { return getElement(3); }
+ /** @return Tab color when selected */
+ public WidgetProperty individual_selected_color() { return getElement(4); }
+ /** @return Tab color when not selected */
+ public WidgetProperty individual_deselected_color() { return getElement(5); }
}
// 'tabs' array
@@ -101,15 +116,18 @@ public TabProperty(final Widget widget, final int index)
private static final WidgetPropertyDescriptor propTabSpacing =
CommonWidgetProperties.newIntegerPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "tab_spacing", "Tab Spacing");
- private static final WidgetPropertyDescriptor propDeselectedColor =
- CommonWidgetProperties.newColorPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "deselected_color", "Deselected Color");
-
+ private static final WidgetPropertyDescriptor propEnablePerTabColors =
+ CommonWidgetProperties.newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "enable_per_tab_colors", "Per Tab Colors");
+ private static final WidgetPropertyDescriptor propDeselectedColor =
+ CommonWidgetProperties.newColorPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "deselected_color", "Deselected Color");
+
private volatile ArrayWidgetProperty tabs;
private volatile WidgetProperty direction;
private volatile WidgetProperty tab_width;
private volatile WidgetProperty tab_height;
private volatile WidgetProperty tab_spacing;
+ private volatile WidgetProperty enable_per_tab_colors;
private volatile WidgetProperty selected_color;
private volatile WidgetProperty deselected_color;
private volatile WidgetProperty font;
@@ -134,8 +152,9 @@ protected void defineProperties(final List> properties)
properties.add(tab_width = propTabWidth.createProperty(this, ActionButtonWidget.DEFAULT_WIDTH));
properties.add(tab_height = propTabHeight.createProperty(this, ActionButtonWidget.DEFAULT_HEIGHT));
properties.add(tab_spacing = propTabSpacing.createProperty(this, 2));
- properties.add(selected_color = propSelectedColor.createProperty(this, new WidgetColor(236, 236, 236)));
- properties.add(deselected_color = propDeselectedColor.createProperty(this, new WidgetColor(200, 200, 200)));
+ properties.add(enable_per_tab_colors = propEnablePerTabColors.createProperty(this, false));
+ properties.add(selected_color = propSelectedColor.createProperty(this, DEFAULT_SELECT_COLOR));
+ properties.add(deselected_color = propDeselectedColor.createProperty(this, DEFAULT_DESELECT_COLOR));
properties.add(font = propFont.createProperty(this, WidgetFontService.get(NamedWidgetFonts.DEFAULT)));
properties.add(active = propActiveTab.createProperty(this, 0));
properties.add(embedded_model = runtimeModel.createProperty(this, null));
@@ -171,6 +190,12 @@ public WidgetProperty propTabSpacing()
return tab_spacing;
}
+ /** @return 'enable_per_tab_colors' property */
+ public WidgetProperty propEnablePerTabColors()
+ {
+ return enable_per_tab_colors;
+ }
+
/** @return 'selected_color' property */
public WidgetProperty propSelectedColor()
{
diff --git a/app/display/model/src/main/resources/examples/controls_spinner_slider_scrollbar.bob b/app/display/model/src/main/resources/examples/controls_spinner_slider_scrollbar.bob
index cc34519523..f3fe26358b 100644
--- a/app/display/model/src/main/resources/examples/controls_spinner_slider_scrollbar.bob
+++ b/app/display/model/src/main/resources/examples/controls_spinner_slider_scrollbar.bob
@@ -1,5 +1,5 @@
-
+Spinner Slider ScrollbarScrollbar
@@ -45,7 +45,6 @@
110220false
- #.##Label_3
@@ -70,18 +69,16 @@
Label_5
- The value can be adjusted both with
-the mouse and, when the widget is in
-focus, using the arrow keys and
-page up/down keys.
+ The value can be adjusted both with the mouse and, when the widget is in focus, using the arrow keys and page up/down keys.
+ Up/Down arrow keys and Page Up/Down keys will change the value by 1 step size
+ Ctrl + Up/Down arrow keys will change the value by 5 x step size
+ Ctrl + Left/Right arrow keys to make changes at 10 x step szie
-The "increment" property of the widget
-determines the step size when using
-the keyboard.
- 143
- 191
- 238
- 189
+The "increment" property of the widget determines the step size when using the keyboard.
+ 141
+ 160
+ 579
+ 250Scaled Slider_1
@@ -90,7 +87,6 @@ the keyboard.
42941961
- #.##false
diff --git a/app/display/model/src/main/resources/examples/initial.bob b/app/display/model/src/main/resources/examples/initial.bob
index 373ebaa445..395243ce17 100644
--- a/app/display/model/src/main/resources/examples/initial.bob
+++ b/app/display/model/src/main/resources/examples/initial.bob
@@ -1,6 +1,6 @@
- Display
+ LabelTITLE
diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/NavigationTabs.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/NavigationTabs.java
index 7af2b89f6d..84d8e1f5de 100644
--- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/NavigationTabs.java
+++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/NavigationTabs.java
@@ -12,7 +12,9 @@
import java.util.concurrent.CopyOnWriteArrayList;
import org.csstudio.display.builder.model.properties.Direction;
+import org.csstudio.display.builder.model.properties.WidgetColor;
import org.csstudio.display.builder.representation.javafx.JFXUtil;
+import org.phoebus.ui.javafx.NonCachingScrollPane;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
@@ -26,7 +28,6 @@
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
-import org.phoebus.ui.javafx.NonCachingScrollPane;
/** Navigation Tabs
*
@@ -72,10 +73,20 @@ public static interface Listener
private final Pane body = new Pane();
/** Labels for the tabs */
- private final List tabs = new CopyOnWriteArrayList<>();
+ private final List tab_names = new CopyOnWriteArrayList<>();
+
+ /** Selected colors for the tabs */
+ private final List tab_selected_colors = new CopyOnWriteArrayList<>();
+ /** Deselected colors for the tabs */
+ private final List tab_deselected_colors = new CopyOnWriteArrayList<>();
+
+ /** Size and spacing for the tabs */
private int tab_width = 100, tab_height = 50, tab_spacing = 2;
+ /** Enable per tab colors */
+ private boolean enable_per_tab_colors = false;
+
/** Direction of tabs */
private Direction direction = Direction.VERTICAL;
@@ -84,6 +95,7 @@ public static interface Listener
deselected = Color.rgb(200, 200, 200);
private Font font = null;
+ private int selected_tab = -1;
/** Listener to selected tab
*
@@ -122,22 +134,22 @@ public void removeListener(final Listener listener)
this.listener = null;
}
- /** @param tabs Tab labels */
- public void setTabs(final List tabs)
+ /** @param tabs Tabs */
+ public void setTabs(final List tab_names, final List tab_selected_colors, final List tab_deselected_colors)
{
- this.tabs.clear();
- this.tabs.addAll(tabs);
+ this.tab_names.clear();
+ this.tab_names.addAll(tab_names);
+ this.tab_selected_colors.clear();
+ this.tab_selected_colors.addAll(tab_selected_colors);
+ this.tab_deselected_colors.clear();
+ this.tab_deselected_colors.addAll(tab_deselected_colors);
updateTabs();
}
/** @return Index of the selected tab. -1 if there are no buttons or nothing selected */
public int getSelectedTab()
{
- final ObservableList siblings = buttons.getChildren();
- for (int i=0; i siblings = buttons.getChildren();
- int i = 0, selected_tab = -1;
+ int i = 0;
+ selected_tab = -1;
+ Color tmpColor = deselected;
+ WidgetColor tmpWidgetColor = null;
for (Node sibling : siblings)
{
final ToggleButton button = (ToggleButton) sibling;
if (button == pressed)
{
+ // Set color to global "selected" color value
+ tmpColor = selected;
// If user clicked a button that was already selected,
// it would now be de-selected, leaving nothing selected.
if (! pressed.isSelected())
@@ -290,14 +341,30 @@ private void handleTabSelection(final ToggleButton pressed, final boolean notify
pressed.setSelected(true);
}
// Highlight active tab by setting it to the 'selected' color
- pressed.setStyle("-fx-color: " + JFXUtil.webRGB(selected));
+ // If the per-tab colors are enabled, the color to apply is to be found in the tab_selected_colors list
+ if (enable_per_tab_colors) {
+ if (i < tab_selected_colors.size()) {
+ tmpWidgetColor = tab_selected_colors.get(i);
+ tmpColor = JFXUtil.convert(tmpWidgetColor);
+ }
+ }
+ pressed.setStyle("-fx-color: " + JFXUtil.webRGB(tmpColor));
selected_tab = i;
}
else if (button.isSelected())
{
// Radio-button behavior: De-select other tabs
button.setSelected(false);
- button.setStyle("-fx-color: " + JFXUtil.webRGB(deselected));
+ // Set color to global "deselected" color value
+ tmpColor = deselected;
+ // If the per-tab colors are enabled, the color to apply is to be found in the tab_deselected_colors list
+ if (enable_per_tab_colors) {
+ if (i < tab_deselected_colors.size()) {
+ tmpWidgetColor = tab_deselected_colors.get(i);
+ tmpColor = JFXUtil.convert(tmpWidgetColor);
+ }
+ }
+ button.setStyle("-fx-color: " + JFXUtil.webRGB(tmpColor));
}
++i;
}
@@ -306,4 +373,4 @@ else if (button.isSelected())
if (selected_tab >= 0 && notify && safe_copy != null)
safe_copy.tabSelected(selected_tab);
}
-}
\ No newline at end of file
+}
diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/NavigationTabsRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/NavigationTabsRepresentation.java
index 6f42f09f06..bb1965770f 100644
--- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/NavigationTabsRepresentation.java
+++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/NavigationTabsRepresentation.java
@@ -24,6 +24,7 @@
import org.csstudio.display.builder.model.Widget;
import org.csstudio.display.builder.model.WidgetProperty;
import org.csstudio.display.builder.model.WidgetPropertyListener;
+import org.csstudio.display.builder.model.properties.WidgetColor;
import org.csstudio.display.builder.model.widgets.NavigationTabsWidget;
import org.csstudio.display.builder.model.widgets.NavigationTabsWidget.TabProperty;
import org.csstudio.display.builder.representation.EmbeddedDisplayRepresentationUtil.DisplayAndGroup;
@@ -98,7 +99,7 @@ private static class SelectedNavigationTabs
*/
private final AtomicReference active_content_model = new AtomicReference<>();
- private final WidgetPropertyListener tab_name_listener = (property, old_value, new_value) ->
+ private final UntypedWidgetPropertyListener tabs_listener = (property, old_value, new_value) ->
{
dirty_tabs.mark();
toolkit.scheduleUpdate(this);
@@ -164,6 +165,7 @@ protected void registerListeners()
model_widget.propTabWidth().addUntypedPropertyListener(tabLookChangedListener);
model_widget.propTabHeight().addUntypedPropertyListener(tabLookChangedListener);
model_widget.propTabSpacing().addUntypedPropertyListener(tabLookChangedListener);
+ model_widget.propEnablePerTabColors().addUntypedPropertyListener(tabLookChangedListener);
model_widget.propSelectedColor().addUntypedPropertyListener(tabLookChangedListener);
model_widget.propDeselectedColor().addUntypedPropertyListener(tabLookChangedListener);
model_widget.propFont().addUntypedPropertyListener(tabLookChangedListener);
@@ -187,6 +189,7 @@ protected void unregisterListeners()
model_widget.propTabWidth().removePropertyListener(tabLookChangedListener);
model_widget.propTabHeight().removePropertyListener(tabLookChangedListener);
model_widget.propTabSpacing().removePropertyListener(tabLookChangedListener);
+ model_widget.propEnablePerTabColors().removePropertyListener(tabLookChangedListener);
model_widget.propSelectedColor().removePropertyListener(tabLookChangedListener);
model_widget.propDeselectedColor().removePropertyListener(tabLookChangedListener);
model_widget.propFont().removePropertyListener(tabLookChangedListener);
@@ -348,10 +351,12 @@ private void removeTabs(final List removed)
{
for (TabProperty tab : removed)
{
- tab.name().removePropertyListener(tab_name_listener);
+ tab.name().removePropertyListener(tabs_listener);
tab.file().removePropertyListener(tab_display_listener);
tab.macros().removePropertyListener(tab_display_listener);
tab.group().removePropertyListener(tab_display_listener);
+ tab.individual_selected_color().removePropertyListener(tabs_listener);
+ tab.individual_deselected_color().removePropertyListener(tabs_listener);
}
}
@@ -359,10 +364,12 @@ private void addTabs(final List added)
{
for (TabProperty tab : added)
{
+ tab.individual_selected_color().addUntypedPropertyListener(tabs_listener);
+ tab.individual_deselected_color().addUntypedPropertyListener(tabs_listener);
tab.group().addUntypedPropertyListener(tab_display_listener);
tab.macros().addUntypedPropertyListener(tab_display_listener);
tab.file().addUntypedPropertyListener(tab_display_listener);
- tab.name().addPropertyListener(tab_name_listener);
+ tab.name().addUntypedPropertyListener(tabs_listener);
}
}
@@ -382,15 +389,25 @@ public void updateChanges()
jfx_node.setTabSize(model_widget.propTabWidth().getValue(),
model_widget.propTabHeight().getValue());
jfx_node.setTabSpacing(model_widget.propTabSpacing().getValue());
+ jfx_node.setEnablePerTabColors(model_widget.propEnablePerTabColors().getValue());
jfx_node.setSelectedColor(JFXUtil.convert(model_widget.propSelectedColor().getValue()));
jfx_node.setDeselectedColor(JFXUtil.convert(model_widget.propDeselectedColor().getValue()));
jfx_node.setFont(JFXUtil.convert(model_widget.propFont().getValue()));
}
if (dirty_tabs.checkAndClear())
{
- final List tabs = new ArrayList<>();
- model_widget.propTabs().getValue().forEach(tab -> tabs.add(tab.name().getValue()));
- jfx_node.setTabs(tabs);
+ final List tab_names = new ArrayList<>();
+ final List tab_selected_colors = new ArrayList<>();
+ final List tab_deselected_colors = new ArrayList<>();
+
+ List tabList = model_widget.propTabs().getValue();
+ tabList.forEach(tab -> {
+ tab_names.add(tab.name().getValue());
+ tab_selected_colors.add(tab.individual_selected_color().getValue());
+ tab_deselected_colors.add(tab.individual_deselected_color().getValue());
+ });
+
+ jfx_node.setTabs(tab_names, tab_selected_colors, tab_deselected_colors);
}
if (dirty_active_tab.checkAndClear())
jfx_node.selectTab(model_widget.propActiveTab().getValue());
diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/SpinnerRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/SpinnerRepresentation.java
index f8ee0881db..aad488ca8c 100644
--- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/SpinnerRepresentation.java
+++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/SpinnerRepresentation.java
@@ -105,16 +105,50 @@ else if(focused){
}
break;
//incrementing by keyboard
+ //isShiftDown(), isControlDown(), isAltDown(), and isMetaDown()
+ case LEFT:
+ if(event.isControlDown()) {
+ spinner.getValueFactory().increment(10);
+ }
+ break;
+ case RIGHT:
+ if(event.isControlDown()) {
+ spinner.getValueFactory().decrement(10);
+ }
+ break;
case UP:
+ if (!active) {
+ if(event.isControlDown()) {
+ spinner.getValueFactory().increment(5);
+ }
+ else {
+ spinner.getValueFactory().increment(1);
+ }
+ }
+ break;
case PAGE_UP:
- if (!active)
- spinner.getValueFactory().increment(1);
+ if (!active) {
+ spinner.getValueFactory().increment(1);
+ }
break;
case DOWN:
+ if (!active) {
+ if(event.isControlDown()) {
+ spinner.getValueFactory().decrement(5);
+ }
+ else {
+ spinner.getValueFactory().decrement(1);
+ }
+ }
+ break;
case PAGE_DOWN:
- if (!active)
- spinner.getValueFactory().decrement(1);
+ if (!active) {
+ spinner.getValueFactory().decrement(1);
+ }
break;
+ case CONTROL:
+ setActive(false);
+ break;
default:
// Any other key results in active state
setActive(true);
@@ -608,3 +642,4 @@ private void setActive(final boolean active)
updateChanges();
}
}
+
diff --git a/app/display/representation-javafx/src/test/java/org/csstudio/display/builder/representation/javafx/sandbox/NavigationTabsDemo.java b/app/display/representation-javafx/src/test/java/org/csstudio/display/builder/representation/javafx/sandbox/NavigationTabsDemo.java
index b68cb16986..24bcad48eb 100644
--- a/app/display/representation-javafx/src/test/java/org/csstudio/display/builder/representation/javafx/sandbox/NavigationTabsDemo.java
+++ b/app/display/representation-javafx/src/test/java/org/csstudio/display/builder/representation/javafx/sandbox/NavigationTabsDemo.java
@@ -8,10 +8,13 @@
package org.csstudio.display.builder.representation.javafx.sandbox;
import java.util.List;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import org.csstudio.display.builder.model.properties.Direction;
+import org.csstudio.display.builder.model.properties.WidgetColor;
import org.csstudio.display.builder.representation.javafx.JFXRepresentation;
import org.csstudio.display.builder.representation.javafx.widgets.NavigationTabs;
import org.phoebus.ui.javafx.ApplicationWrapper;
@@ -34,8 +37,10 @@ public void start(final Stage stage)
{
final NavigationTabs nav_tabs = new NavigationTabs();
- final List tabs = IntStream.range(1, 10).mapToObj(i -> "Step" + i).collect(Collectors.toList());
- nav_tabs.setTabs(tabs);
+ final List tab_names = IntStream.range(1, 10).mapToObj(i -> "Step" + i).collect(Collectors.toList());
+ final List tab_selected_colors = new ArrayList<>(Collections.nCopies(10, new WidgetColor(236,236,236)));
+ final List tab_deselected_colors = new ArrayList<>(Collections.nCopies(10, new WidgetColor(200,200,200)));
+ nav_tabs.setTabs(tab_names, tab_selected_colors, tab_deselected_colors);
nav_tabs.setTabSize(80, 40);
nav_tabs.setTabSpacing(5);
nav_tabs.getBodyPane().getChildren().setAll(new Label(" Go on, select something!"));
diff --git a/app/logbook/olog/ui/doc/index.rst b/app/logbook/olog/ui/doc/index.rst
index ba1d8ef429..e6d2cb8c84 100644
--- a/app/logbook/olog/ui/doc/index.rst
+++ b/app/logbook/olog/ui/doc/index.rst
@@ -46,6 +46,7 @@ The log entry editor may also be launched from context menus, where applicable.
the background of an OPI the launched context menu will include the Create Log item:
.. image:: images/ContextMenu.png
+
The Create Log context menu item is available also in a Databrowser plot area.
Editing a log entry
@@ -74,6 +75,7 @@ When the log entry editor is launched from a context menu, a screen shot is auto
Additional images (or other type of attachments) may be added by expanding the Attachments editor:
.. image:: images/Attachments.png
+
Here user may attach any number of files of arbitrary types:
- :guilabel:`Add Files` will launch the native file browser dialog from which user may select any number of files.
@@ -103,6 +105,7 @@ Images may be embedded in the body text using markup. The user should consult th
for details on how to do this. In general, users should use the Embed Image button to add image markup at the cursor position:
.. image:: images/EmbedImage.png
+
External image resources may be edited manually, e.g.:
````.
File URLs are not supported.
@@ -117,6 +120,7 @@ Properties are edited by expanding the Properties editor. The below screen shot
(LCR shift info) holding five keys has been configured in the service:
.. image:: images/PropertiesEditor.png
+
User may select what properties to include in the log entry, and edit the values for the items in the property.
diff --git a/app/save-and-restore/app/doc/images/compare-arrays-infinite-delta.png b/app/save-and-restore/app/doc/images/compare-arrays-infinite-delta.png
new file mode 100644
index 0000000000..cc6ccf97fd
Binary files /dev/null and b/app/save-and-restore/app/doc/images/compare-arrays-infinite-delta.png differ
diff --git a/app/save-and-restore/app/doc/images/compare-arrays.png b/app/save-and-restore/app/doc/images/compare-arrays.png
new file mode 100644
index 0000000000..f5a80fb9ec
Binary files /dev/null and b/app/save-and-restore/app/doc/images/compare-arrays.png differ
diff --git a/app/save-and-restore/app/doc/images/snapshot-view-with-delta.png b/app/save-and-restore/app/doc/images/snapshot-view-with-delta.png
new file mode 100644
index 0000000000..f51479abcb
Binary files /dev/null and b/app/save-and-restore/app/doc/images/snapshot-view-with-delta.png differ
diff --git a/app/save-and-restore/app/doc/index.rst b/app/save-and-restore/app/doc/index.rst
index f826a173fc..313969b94b 100644
--- a/app/save-and-restore/app/doc/index.rst
+++ b/app/save-and-restore/app/doc/index.rst
@@ -303,6 +303,29 @@ are shown by default. The left-most columns in the toolbar can be used to show/h
.. image:: images/toggle-readback.png
:width: 80%
+While comparison of scalar values in the snapshot view is straight-forward, array (or table) type data is difficult
+to compare from the single table cells. User may instead click on the highlighted ":math:`{\Delta}` Live" cell to launch a dialog
+showing stored, live and :math:`{\Delta}` for the selected PV:
+
+.. image:: images/snapshot-view-with-delta.png
+
+User clicks "Click to compare":
+
+.. image:: images/compare-arrays.png
+
+The threshold settings works in the same manner is in the snapshot view and operates on each element (row) in the
+table view.
+
+In case the stored and live value of the array/table data are of different dimensions, cells where no value is available
+will be rendered as "---". Moreover, since in these cases an absolute delta cannot be computed, the delta column will also show
+"---".
+
+User may click the table header of the delta column to sort on the delta value to quickly find rows where either the
+stored or live value is not defined (due to difference in dimension). For such rows the absolute delta will be treated
+as infinite, which impacts ordering on the delta column:
+
+.. image:: images/compare-arrays-infinite-delta.png
+
Restoring A Snapshot
--------------------
@@ -316,12 +339,14 @@ write operations is hence undefined.
Prior to restore user has the option to:
* Exclude PVs using the checkboxes in the left-most column. To simplify selection, user may use the Filter input field to find matching PV names:
-.. image:: images/snapshot-restore-filter.png
- :width: 80%
+
+ .. image:: images/snapshot-restore-filter.png
+ :width: 80%
* Specify a multiplier factor :math:`{\neq}` 1 applied to scalar data type PVs:
-.. image:: images/restore-with-scale.png
- :width: 80%
+
+ .. image:: images/restore-with-scale.png
+ :width: 80%
Restoring from a composite snapshot works in the same manner as the restore operation from a single-snapshot.
diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java
index db0047186a..38a8034493 100644
--- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java
+++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java
@@ -32,6 +32,7 @@ public class Messages {
public static String buttonSearch;
public static String cannotCompareHeader;
public static String cannotCompareTitle;
+ public static String clickToCompare;
public static String closeConfigurationWarning;
public static String closeCompositeSnapshotWarning;
public static String closeSnapshotWarning;
@@ -39,6 +40,8 @@ public class Messages {
public static String closeConfigurationTabPrompt;
public static String closeSnapshotTabPrompt;
public static String compositeSnapshotConsistencyCheckFailed;
+ public static String comparisonDialogLunchError;
+ public static String comparisonDialogTitle;
public static String contextMenuAddTag;
@Deprecated
public static String contextMenuAddTagWithComment;
@@ -107,6 +110,7 @@ public class Messages {
public static String importSnapshotLabel;
public static String includeThisPV;
public static String inverseSelection;
+ public static String live;
public static String liveReadbackVsSetpoint;
public static String liveSetpoint;
public static String login;
@@ -159,6 +163,7 @@ public class Messages {
public static String snapshotFromPvs;
public static String status;
public static String storedReadbackValue;
+ public static String stored;
public static String storedValues;
public static String tableColumnDeltaValue;
public static String tagAddFailed;
diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreController.java
index a85f72e44c..b658453f9f 100644
--- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreController.java
+++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/SaveAndRestoreController.java
@@ -383,7 +383,6 @@ public void loadInitialData() {
JobManager.schedule("Load save-and-restore tree data", monitor -> {
Node rootNode = saveAndRestoreService.getRootNode();
- treeInitializationCountDownLatch.countDown();
TreeItem rootItem = createTreeItem(rootNode);
List savedTreeViewStructure = getSavedTreeStructure();
@@ -467,6 +466,7 @@ protected void expandTreeNode(TreeItem targetItem) {
childNodes.stream().map(this::createTreeItem).toList();
targetItem.getChildren().setAll(list);
targetItem.getChildren().sort(treeNodeComparator);
+ targetItem.setExpanded(true);
}
/**
@@ -861,7 +861,9 @@ public void locateNode(Stack nodeStack) {
while (!nodeStack.isEmpty()) {
Node currentNode = nodeStack.pop();
TreeItem currentTreeItem = recursiveSearch(currentNode.getUniqueId(), parentTreeItem);
- expandTreeNode(currentTreeItem);
+ if(!currentTreeItem.isExpanded()){
+ expandTreeNode(currentTreeItem);
+ }
parentTreeItem = currentTreeItem;
}
@@ -1349,9 +1351,7 @@ public void secureStoreChanged(List validTokens) {
private void openNode(String nodeId) {
JobManager.schedule("Open save-and-restore node", monitor -> {
try {
- if (!treeInitializationCountDownLatch.await(30000, TimeUnit.SECONDS)) {
- return;
- }
+ treeInitializationCountDownLatch.await();
} catch (InterruptedException e) {
logger.log(Level.WARNING, "Failed to await tree view to load", e);
return;
diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/VTypePair.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/VTypePair.java
index 1d71acd33b..0942d11ef5 100644
--- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/VTypePair.java
+++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/VTypePair.java
@@ -17,8 +17,12 @@
*/
package org.phoebus.applications.saveandrestore.ui;
+import org.epics.vtype.VNumber;
+import org.epics.vtype.VString;
import org.epics.vtype.VType;
+import org.phoebus.core.vtypes.VTypeHelper;
import org.phoebus.saveandrestore.util.Threshold;
+import org.phoebus.saveandrestore.util.VNoData;
import java.util.Optional;
@@ -35,7 +39,8 @@ public class VTypePair {
public final Optional> threshold;
/**
- * Constructs a new pair.
+ * Constructs a new pair. In the context of save-and-restore snapshots, the {@link #value} field
+ * is used to hold a stored value, while {@link #base} holds the live PV value.
*
* @param base the base value
* @param value the value that can be compared to base
@@ -47,6 +52,39 @@ public VTypePair(VType base, VType value, Optional> threshold) {
this.threshold = threshold;
}
+ /**
+ * Computes absolute delta for the delta between {@link #base} and {@link #value}. When applied to
+ * {@link VString} types, {@link String#compareTo(String)} is used for comparison, but then converted to
+ * an absolute value.
+ *
+ *
+ * Main use case for this is ordering on delta. Absolute delta may be more useful as otherwise zero
+ * deltas would be found between positive and negative deltas.
+ *
+ *
+ * If {@link #base} or {@link #value} are null or {@link VNoData#INSTANCE}, then
+ * the delta cannot be computed as a number. In this case {@link Double#MAX_VALUE} is returned
+ * to indicate an "infinite delta".
+ *
+ * @return Absolute delta between {@link #base} and {@link #value}.
+ */
+ public double getAbsoluteDelta(){
+ if(base.equals(VNoData.INSTANCE) ||
+ value.equals(VNoData.INSTANCE) ||
+ base == null ||
+ value == null){
+ return Double.MAX_VALUE;
+ }
+ if(base instanceof VNumber){
+ return Math.abs(((VNumber)base).getValue().doubleValue() -
+ ((VNumber)value).getValue().doubleValue());
+ }
+ else if(base instanceof VString){
+ return Math.abs(((VString)base).getValue().compareTo(((VString)value).getValue()));
+ }
+ else return 0.0;
+ }
+
/*
* (non-Javadoc)
*
diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java
index 6c7eaca1f6..0e5781836f 100644
--- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java
+++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java
@@ -645,7 +645,7 @@ protected void updateItem(TableEntry item, boolean empty) {
});
showDeltaPercentage.addListener((ob, o, n) -> deltaColumn.setCellFactory(e -> {
- VDeltaCellEditor vDeltaCellEditor = new VDeltaCellEditor<>();
+ VDeltaCellEditor vDeltaCellEditor = new VDeltaCellEditor<>();
vDeltaCellEditor.setShowDeltaPercentage(n);
return vDeltaCellEditor;
}));
@@ -1453,7 +1453,7 @@ private void addSnapshot(Snapshot snapshot) {
"", minWidth);
deltaCol.setCellValueFactory(e -> e.getValue().compareValueProperty(additionalSnapshots.size()));
deltaCol.setCellFactory(e -> {
- VDeltaCellEditor vDeltaCellEditor = new VDeltaCellEditor<>();
+ VDeltaCellEditor vDeltaCellEditor = new VDeltaCellEditor<>();
vDeltaCellEditor.setShowDeltaPercentage(showDeltaPercentage.get());
return vDeltaCellEditor;
});
diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VDeltaCellEditor.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VDeltaCellEditor.java
index a3a8d0ead3..faae91e738 100644
--- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VDeltaCellEditor.java
+++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VDeltaCellEditor.java
@@ -20,10 +20,16 @@
package org.phoebus.applications.saveandrestore.ui.snapshot;
import javafx.scene.control.Tooltip;
+import org.epics.vtype.VEnumArray;
+import org.epics.vtype.VNumberArray;
+import org.epics.vtype.VStringArray;
+import org.phoebus.applications.saveandrestore.Messages;
import org.phoebus.applications.saveandrestore.ui.VTypePair;
+import org.phoebus.applications.saveandrestore.ui.snapshot.compare.ComparisonDialog;
import org.phoebus.core.vtypes.VDisconnectedData;
import org.phoebus.saveandrestore.util.Utilities;
import org.phoebus.saveandrestore.util.VNoData;
+import org.phoebus.ui.dialog.DialogHelper;
import java.util.Formatter;
@@ -34,7 +40,7 @@
* @param
* @author Kunal Shroff
*/
-public class VDeltaCellEditor extends VTypeCellEditor {
+public class VDeltaCellEditor extends VTypeCellEditor {
private final Tooltip tooltip = new Tooltip();
@@ -44,7 +50,7 @@ protected void setShowDeltaPercentage(boolean showDeltaPercentage) {
this.showDeltaPercentage = showDeltaPercentage;
}
- VDeltaCellEditor() {
+ public VDeltaCellEditor() {
super();
}
@@ -72,17 +78,36 @@ public void updateItem(T item, boolean empty) {
setStyle(TableCellColors.DISCONNECTED_STYLE);
} else if (pair.value == VNoData.INSTANCE) {
setText(pair.value.toString());
- } else {
+ } else if(pair.base == VNoData.INSTANCE){
+ setText(VNoData.INSTANCE.toString());
+ }
+ else {
Utilities.VTypeComparison vtc = Utilities.deltaValueToString(pair.value, pair.base, pair.threshold);
- String percentage = Utilities.deltaValueToPercentage(pair.value, pair.base);
- if (!percentage.isEmpty() && showDeltaPercentage) {
- Formatter formatter = new Formatter();
- setText(formatter.format("%g", Double.parseDouble(vtc.getString())) + " (" + percentage + ")");
- } else {
- setText(vtc.getString());
- }
- if (!vtc.isWithinThreshold()) {
+ if (vtc.getValuesEqual() != 0 &&
+ (pair.base instanceof VNumberArray ||
+ pair.base instanceof VStringArray ||
+ pair.base instanceof VEnumArray)) {
+ TableEntry tableEntry = (TableEntry) getTableRow().getItem();
+ setText(Messages.clickToCompare);
setStyle(TableCellColors.ALARM_MAJOR_STYLE);
+ setOnMouseClicked(e -> {
+ ComparisonDialog comparisonDialog = new ComparisonDialog(tableEntry.getSnapshotVal().get(), tableEntry.getConfigPv().getPvName());
+ DialogHelper.positionDialog(comparisonDialog, getTableView(), -400, -400);
+ comparisonDialog.show();
+ });
+ } else {
+ // Do not handle mouse clicked, e.g. if live PV is disconnected.
+ setOnMouseClicked(e -> {});
+ String percentage = Utilities.deltaValueToPercentage(pair.value, pair.base);
+ if (!percentage.isEmpty() && showDeltaPercentage) {
+ Formatter formatter = new Formatter();
+ setText(formatter.format("%g", Double.parseDouble(vtc.getString())) + " (" + percentage + ")");
+ } else {
+ setText(vtc.getString());
+ }
+ if (!vtc.isWithinThreshold()) {
+ setStyle(TableCellColors.ALARM_MAJOR_STYLE);
+ }
}
}
tooltip.setText(item.toString());
diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VTypeCellEditor.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VTypeCellEditor.java
index 032676186e..ff10c3a32e 100644
--- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VTypeCellEditor.java
+++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VTypeCellEditor.java
@@ -49,10 +49,10 @@
* @param {@link org.epics.vtype.VType} or {@link org.phoebus.applications.saveandrestore.ui.VTypePair}
* @author Jaka Bobnar
*/
-public class VTypeCellEditor extends MultitypeTableCell {
+public class VTypeCellEditor extends MultitypeTableCell {
private final Tooltip tooltip = new Tooltip();
- VTypeCellEditor() {
+ public VTypeCellEditor() {
setConverter(new StringConverter<>() {
@Override
diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ColumnEntry.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ColumnEntry.java
new file mode 100644
index 0000000000..6246262ab3
--- /dev/null
+++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ColumnEntry.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2025 European Spallation Source ERIC.
+ */
+
+package org.phoebus.applications.saveandrestore.ui.snapshot.compare;
+
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import org.epics.vtype.VNumber;
+import org.epics.vtype.VType;
+import org.phoebus.applications.saveandrestore.SafeMultiply;
+import org.phoebus.applications.saveandrestore.ui.VTypePair;
+import org.phoebus.saveandrestore.util.Threshold;
+import org.phoebus.saveandrestore.util.Utilities;
+import org.phoebus.saveandrestore.util.VNoData;
+
+import java.util.Optional;
+
+/**
+ * Data class for one column in the comparison table.
+ */
+public class ColumnEntry {
+
+ /**
+ * The {@link VType} value as stored in a {@link org.phoebus.applications.saveandrestore.model.Snapshot}
+ */
+ private final ObjectProperty storedValue = new SimpleObjectProperty<>(this, "storedValue", null);
+ /**
+ * A {@link VTypePair} property holding data for the purpose of calculating and showing a delta.
+ */
+ private final ObjectProperty delta = new SimpleObjectProperty<>(this, "delta", null);
+ /**
+ * The live {@link VType} value as read from a connected PV.
+ */
+ private final ObjectProperty liveValue = new SimpleObjectProperty<>(this, "liveValue", VNoData.INSTANCE);
+
+ private Optional> threshold = Optional.empty();
+
+ public ColumnEntry(VType storedValue) {
+ this.storedValue.set(storedValue);
+ }
+
+ public ObjectProperty storedValueProperty() {
+ return storedValue;
+ }
+
+ public void setLiveVal(VType liveValue) {
+ this.liveValue.set(liveValue);
+ VTypePair vTypePair = new VTypePair(liveValue, storedValue.get(), threshold);
+ delta.set(vTypePair);
+ }
+
+ public ObjectProperty liveValueProperty() {
+ return liveValue;
+ }
+
+ public ObjectProperty getDelta() {
+ return delta;
+ }
+
+ /**
+ * Set the threshold value for this entry. All value comparisons related to this entry are calculated using the
+ * threshold (if it exists).
+ *
+ * @param ratio the threshold
+ */
+ public void setThreshold(double ratio) {
+ if (storedValue.get() instanceof VNumber) {
+ VNumber vNumber = SafeMultiply.multiply((VNumber) storedValue.get(), ratio);
+ boolean isNegative = vNumber.getValue().doubleValue() < 0;
+ Threshold t = new Threshold<>(isNegative ? SafeMultiply.multiply(vNumber.getValue(), -1.0) : vNumber.getValue());
+ this.delta.set(new VTypePair(liveValue.get(), storedValue.get(), Optional.of(t)));
+ }
+ }
+}
diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonData.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonData.java
new file mode 100644
index 0000000000..7026add0d2
--- /dev/null
+++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonData.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2025 European Spallation Source ERIC.
+ */
+
+package org.phoebus.applications.saveandrestore.ui.snapshot.compare;
+
+import javafx.beans.property.IntegerProperty;
+import javafx.beans.property.SimpleIntegerProperty;
+import org.epics.vtype.VType;
+import org.phoebus.applications.saveandrestore.ui.VTypePair;
+import org.phoebus.saveandrestore.util.Threshold;
+import org.phoebus.saveandrestore.util.Utilities;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Data class for the {@link javafx.scene.control.TableView} of the comparison dialog.
+ */
+public class ComparisonData {
+
+ /**
+ * Index (=row number) for this instance.
+ */
+ private final IntegerProperty index = new SimpleIntegerProperty(this, "index");
+ /**
+ * {@link List} of {@link ColumnEntry}s, one for each column in the data. For array data this will
+ * hold only one element.
+ */
+ private final List columnEntries;
+
+ public ComparisonData(int index, List columnEntries) {
+ this.index.set(index);
+ this.columnEntries = columnEntries;
+ }
+
+ @SuppressWarnings("unused")
+ public IntegerProperty indexProperty() {
+ return index;
+ }
+
+ public List getColumnEntries() {
+ return columnEntries;
+ }
+
+ /**
+ * Set the threshold value for this entry. All value comparisons related to this entry are calculated using the
+ * threshold (if it exists).
+ *
+ * @param ratio the threshold
+ */
+ public void setThreshold(double ratio) {
+ columnEntries.forEach(columnEntry -> columnEntry.setThreshold(ratio));
+ }
+}
diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialog.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialog.java
new file mode 100644
index 0000000000..9f531b8e4f
--- /dev/null
+++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialog.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2025 European Spallation Source ERIC.
+ */
+
+package org.phoebus.applications.saveandrestore.ui.snapshot.compare;
+
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Node;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.Dialog;
+import org.epics.vtype.VType;
+import org.phoebus.applications.saveandrestore.Messages;
+import org.phoebus.framework.nls.NLS;
+
+import java.io.IOException;
+import java.util.ResourceBundle;
+
+/**
+ * Dialog showing a {@link javafx.scene.control.TableView} where array or table data is visualized element wise.
+ * Purpose is to be able to inspect deltas on array/table element level.
+ *
+ *
+ * Data in the {@link javafx.scene.control.TableView} is organized column wise. Each row in the {@link javafx.scene.control.TableView}
+ * corresponds to an individual element in the data. Each column contains three nested columns: stored value,
+ * delta and live value.
+ *
+ *
+ * For an array type ({@link org.epics.vtype.VNumberArray} the table will thus hold a single data column. For a
+ * table type ({@link org.epics.vtype.VTable} there will be one data column for each column in the table.
+ *
+ */
+public class ComparisonDialog extends Dialog {
+
+ /**
+ * Constructor
+ * @param data The data as stored in a {@link org.phoebus.applications.saveandrestore.model.Snapshot}
+ * @param pvName The name of the for which
+ */
+ public ComparisonDialog(VType data, String pvName){
+
+ getDialogPane().getButtonTypes().addAll(ButtonType.CLOSE);
+ setResizable(true);
+ setTitle(Messages.comparisonDialogTitle);
+
+ ResourceBundle resourceBundle = NLS.getMessages(Messages.class);
+ FXMLLoader loader = new FXMLLoader();
+ loader.setResources(resourceBundle);
+ loader.setLocation(this.getClass().getResource("TableComparisonView.fxml"));
+ try {
+ Node node = loader.load();
+ TableComparisonViewController controller = loader.getController();
+ controller.loadDataAndConnect(data, pvName);
+ getDialogPane().setContent(node);
+ setOnCloseRequest(e -> controller.cleanUp());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonViewController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonViewController.java
new file mode 100644
index 0000000000..ce6f296cc7
--- /dev/null
+++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonViewController.java
@@ -0,0 +1,406 @@
+/*
+ * Copyright (C) 2025 European Spallation Source ERIC.
+ */
+
+package org.phoebus.applications.saveandrestore.ui.snapshot.compare;
+
+
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+import javafx.fxml.FXML;
+import javafx.geometry.Pos;
+import javafx.scene.control.Label;
+import javafx.scene.control.Spinner;
+import javafx.scene.control.SpinnerValueFactory;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import javafx.scene.control.Tooltip;
+import javafx.util.converter.DoubleStringConverter;
+import org.epics.util.array.ListBoolean;
+import org.epics.vtype.VBoolean;
+import org.epics.vtype.VBooleanArray;
+import org.epics.vtype.VByte;
+import org.epics.vtype.VByteArray;
+import org.epics.vtype.VDouble;
+import org.epics.vtype.VDoubleArray;
+import org.epics.vtype.VEnumArray;
+import org.epics.vtype.VFloat;
+import org.epics.vtype.VFloatArray;
+import org.epics.vtype.VInt;
+import org.epics.vtype.VIntArray;
+import org.epics.vtype.VLong;
+import org.epics.vtype.VLongArray;
+import org.epics.vtype.VNumberArray;
+import org.epics.vtype.VShort;
+import org.epics.vtype.VShortArray;
+import org.epics.vtype.VString;
+import org.epics.vtype.VStringArray;
+import org.epics.vtype.VType;
+import org.epics.vtype.VUByte;
+import org.epics.vtype.VUByteArray;
+import org.epics.vtype.VUInt;
+import org.epics.vtype.VUIntArray;
+import org.epics.vtype.VULong;
+import org.epics.vtype.VULongArray;
+import org.epics.vtype.VUShort;
+import org.epics.vtype.VUShortArray;
+import org.phoebus.applications.saveandrestore.Messages;
+import org.phoebus.applications.saveandrestore.ui.VTypePair;
+import org.phoebus.applications.saveandrestore.ui.snapshot.VDeltaCellEditor;
+import org.phoebus.applications.saveandrestore.ui.snapshot.VTypeCellEditor;
+import org.phoebus.core.vtypes.VDisconnectedData;
+import org.phoebus.core.vtypes.VTypeHelper;
+import org.phoebus.pv.PV;
+import org.phoebus.pv.PVPool;
+import org.phoebus.saveandrestore.util.Utilities;
+import org.phoebus.saveandrestore.util.VNoData;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Controller class for the comparison table view.
+ */
+public class TableComparisonViewController {
+
+ @SuppressWarnings("unused")
+ @FXML
+ private TableView comparisonTable;
+
+ @SuppressWarnings("unused")
+ @FXML
+ private TableColumn indexColumn;
+
+ @SuppressWarnings("unused")
+ @FXML
+ private TableColumn storedValueColumn;
+
+ @SuppressWarnings("unused")
+ @FXML
+ private TableColumn liveValueColumn;
+
+ @SuppressWarnings("unused")
+ @FXML
+ private TableColumn deltaColumn;
+
+ @SuppressWarnings("unused")
+ @FXML
+ private Spinner thresholdSpinner;
+
+ @SuppressWarnings("unused")
+ @FXML
+ private Label pvName;
+
+ @SuppressWarnings("unused")
+ @FXML
+ private Label dimensionStored;
+
+ @SuppressWarnings("unused")
+ @FXML
+ private Label dimensionLive;
+
+ @SuppressWarnings("unused")
+ @FXML
+ private Label nonEqualCount;
+
+
+ private final StringProperty pvNameProperty = new SimpleStringProperty();
+ private final StringProperty dimensionStoredProperty = new SimpleStringProperty();
+ private final StringProperty dimensionLiveProperty = new SimpleStringProperty();
+ private final StringProperty nonEqualCountProperty = new SimpleStringProperty("0");
+
+ private PV pv;
+
+ /**
+ * The time between updates of dynamic data in the table, in ms.
+ */
+ private static final long TABLE_UPDATE_INTERVAL = 500;
+
+ @FXML
+ public void initialize() {
+ comparisonTable.getStylesheets().add(TableComparisonViewController.class.getResource("/save-and-restore-style.css").toExternalForm());
+ pvName.textProperty().bind(pvNameProperty);
+ storedValueColumn.setCellValueFactory(cell ->
+ cell.getValue().getColumnEntries().get(0).storedValueProperty());
+ storedValueColumn.setCellFactory(e -> new VTypeCellEditor<>());
+ liveValueColumn.setCellValueFactory(cell ->
+ cell.getValue().getColumnEntries().get(0).liveValueProperty());
+ liveValueColumn.setCellFactory(e -> new VTypeCellEditor<>());
+ deltaColumn.setCellValueFactory(cell ->
+ cell.getValue().getColumnEntries().get(0).getDelta());
+ deltaColumn.setComparator(Comparator.comparingDouble(VTypePair::getAbsoluteDelta));
+
+ deltaColumn.setCellFactory(e -> new VDeltaCellEditor<>());
+
+ SpinnerValueFactory thresholdSpinnerValueFactory = new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 999.0, 0.0, 0.01);
+ thresholdSpinnerValueFactory.setConverter(new DoubleStringConverter());
+ thresholdSpinner.setValueFactory(thresholdSpinnerValueFactory);
+ thresholdSpinner.getEditor().setAlignment(Pos.CENTER_RIGHT);
+ thresholdSpinner.getEditor().textProperty().addListener((a, o, n) -> parseAndUpdateThreshold(n));
+
+ dimensionLive.textProperty().bind(dimensionLiveProperty);
+ dimensionStored.textProperty().bind(dimensionStoredProperty);
+ nonEqualCount.textProperty().bind(nonEqualCountProperty);
+ }
+
+ /**
+ * Loads snapshot data and then connects to the corresponding PV.
+ *
+ * @param data Data as stored in a {@link org.phoebus.applications.saveandrestore.model.Snapshot}
+ * @param pvName The name of the PV.
+ */
+ public void loadDataAndConnect(VType data, String pvName) {
+
+ pvNameProperty.set(pvName);
+
+ int arraySize = VTypeHelper.getArraySize(data);
+ for (int index = 0; index < arraySize; index++) {
+ List columnEntries = new ArrayList<>();
+ ColumnEntry columnEntry = null;
+ if (data instanceof VNumberArray) {
+ if (data instanceof VDoubleArray array) {
+ double value = array.getData().getDouble(index);
+ columnEntry = new ColumnEntry(VDouble.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (data instanceof VFloatArray array) {
+ float value = array.getData().getFloat(index);
+ columnEntry = new ColumnEntry(VFloat.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (data instanceof VIntArray array) {
+ int value = array.getData().getInt(index);
+ columnEntry = new ColumnEntry(VInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (data instanceof VUIntArray array) {
+ int value = array.getData().getInt(index);
+ columnEntry = new ColumnEntry(VUInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (data instanceof VLongArray array) {
+ long value = array.getData().getLong(index);
+ columnEntry = new ColumnEntry(VLong.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (data instanceof VULongArray array) {
+ long value = array.getData().getLong(index);
+ columnEntry = new ColumnEntry(VULong.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (data instanceof VShortArray array) {
+ short value = array.getData().getShort(index);
+ columnEntry = new ColumnEntry(VShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (data instanceof VUShortArray array) {
+ short value = array.getData().getShort(index);
+ columnEntry = new ColumnEntry(VUShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (data instanceof VByteArray array) {
+ byte value = array.getData().getByte(index);
+ columnEntry = new ColumnEntry(VByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (data instanceof VUByteArray array) {
+ byte value = array.getData().getByte(index);
+ columnEntry = new ColumnEntry(VUByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ }
+ }
+ else if (data instanceof VBooleanArray array) {
+ ListBoolean listBoolean = array.getData();
+ boolean value = listBoolean.getBoolean(index);
+ columnEntry = new ColumnEntry(VBoolean.of(value, array.getAlarm(), array.getTime()));
+ } else if (data instanceof VEnumArray array) {
+ List enumValues = array.getData();
+ columnEntry = new ColumnEntry(VString.of(enumValues.get(index), array.getAlarm(), array.getTime()));
+ } else if (data instanceof VStringArray array) {
+ List stringValues = array.getData();
+ columnEntry = new ColumnEntry(VString.of(stringValues.get(index), array.getAlarm(), array.getTime()));
+ }
+ if(columnEntry != null){
+ addRow(index, columnEntries, columnEntry);
+ }
+ }
+
+ // Hard coded column count until we support VTable
+ dimensionStoredProperty.set(arraySize + " x 1");
+ connect();
+ }
+
+ private void addRow(int index, List columnEntries, ColumnEntry columnEntry) {
+ columnEntries.add(columnEntry);
+ ComparisonData comparisonData = new ComparisonData(index, columnEntries);
+ comparisonTable.getItems().add(index, comparisonData);
+ }
+
+ /**
+ * Attempts to connect to the PV.
+ */
+ private void connect() {
+ try {
+ pv = PVPool.getPV(pvNameProperty.get());
+ pv.onValueEvent().throttleLatest(TABLE_UPDATE_INTERVAL, TimeUnit.MILLISECONDS)
+ .subscribe(value -> updateTable(PV.isDisconnected(value) ? VDisconnectedData.INSTANCE : value));
+ } catch (Exception e) {
+ Logger.getLogger(TableComparisonViewController.class.getName()).log(Level.INFO, "Error connecting to PV", e);
+ }
+ }
+
+ /**
+ * Returns PV to pool, e.g. when UI is dismissed.
+ */
+ public void cleanUp() {
+ if (pv != null) {
+ PVPool.releasePV(pv);
+ }
+ }
+
+ /**
+ * Updates the {@link TableView} from the live data acquired through a PV monitor event.
+ * Differences in data sizes between stored and live data is considered.
+ *
+ * @param liveData EPICS data from the connected PV, or {@link VDisconnectedData#INSTANCE}.
+ */
+ private void updateTable(VType liveData) {
+ if (liveData.equals(VDisconnectedData.INSTANCE)) {
+ comparisonTable.getItems().forEach(i -> i.getColumnEntries().get(0).setLiveVal(VDisconnectedData.INSTANCE));
+ } else {
+ int liveDataArraySize = VTypeHelper.getArraySize(liveData);
+ comparisonTable.getItems().forEach(i -> {
+ int index = i.indexProperty().get();
+ ColumnEntry columnEntry = i.getColumnEntries().get(0);
+ if (liveData instanceof VNumberArray) {
+ if (index >= liveDataArraySize) { // Live data has fewer elements than stored data
+ columnEntry.setLiveVal(VNoData.INSTANCE);
+ } else if (liveData instanceof VDoubleArray array) {
+ columnEntry.setLiveVal(VDouble.of(array.getData().getDouble(index), array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VShortArray array) {
+ columnEntry.setLiveVal(VShort.of(array.getData().getShort(index), array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VIntArray array) {
+ columnEntry.setLiveVal(VInt.of(array.getData().getInt(index), array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VUIntArray array) {
+ columnEntry.setLiveVal(VUInt.of(array.getData().getInt(index), array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VLongArray array) {
+ columnEntry.setLiveVal(VLong.of(array.getData().getLong(index), array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VULongArray array) {
+ columnEntry.setLiveVal(VULong.of(array.getData().getLong(index), array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VFloatArray array) {
+ columnEntry.setLiveVal(VFloat.of(array.getData().getFloat(index), array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VShortArray array) {
+ columnEntry.setLiveVal(VUShort.of(array.getData().getShort(index), array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VUShortArray array) {
+ columnEntry.setLiveVal(VShort.of(array.getData().getShort(index), array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VByteArray array) {
+ columnEntry.setLiveVal(VByte.of(array.getData().getShort(index), array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VUByteArray array) {
+ columnEntry.setLiveVal(VUByte.of(array.getData().getShort(index), array.getAlarm(), array.getTime(), array.getDisplay()));
+ }
+ } else if (liveData instanceof VBooleanArray array) {
+ if (index >= array.getData().size()) { // Live data has fewer elements than stored data
+ columnEntry.setLiveVal(VNoData.INSTANCE);
+ } else {
+ columnEntry.setLiveVal(VBoolean.of(array.getData().getBoolean(index), array.getAlarm(), array.getTime()));
+ }
+ } else if (liveData instanceof VEnumArray array) {
+ if (index >= array.getData().size()) { // Live data has fewer elements than stored data
+ columnEntry.setLiveVal(VNoData.INSTANCE);
+ } else {
+ columnEntry.setLiveVal(VString.of(array.getData().get(index), array.getAlarm(), array.getTime()));
+ }
+ } else if (liveData instanceof VStringArray array) {
+ if (index >= array.getData().size()) { // Live data has fewer elements than stored data
+ columnEntry.setLiveVal(VNoData.INSTANCE);
+ } else {
+ columnEntry.setLiveVal(VString.of(array.getData().get(index), array.getAlarm(), array.getTime()));
+ }
+ }
+ });
+ // Live data may have more elements than stored data
+ if (liveDataArraySize > comparisonTable.getItems().size()) {
+ List columnEntries = new ArrayList<>();
+ for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) {
+ ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE);
+ if (liveData instanceof VNumberArray) {
+ if (liveData instanceof VDoubleArray array) {
+ double value = array.getData().getDouble(index);
+ columnEntry.setLiveVal(VDouble.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VFloatArray array) {
+ float value = array.getData().getFloat(index);
+ columnEntry.setLiveVal(VFloat.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VIntArray array) {
+ int value = array.getData().getInt(index);
+ columnEntry.setLiveVal(VInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VUIntArray array) {
+ int value = array.getData().getInt(index);
+ columnEntry.setLiveVal(VUInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VLongArray array) {
+ long value = array.getData().getLong(index);
+ columnEntry.setLiveVal(VLong.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VULongArray array) {
+ long value = array.getData().getLong(index);
+ columnEntry.setLiveVal(VULong.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VShortArray array) {
+ short value = array.getData().getShort(index);
+ columnEntry.setLiveVal(VShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VUShortArray array) {
+ short value = array.getData().getShort(index);
+ columnEntry.setLiveVal(VUShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VByteArray array) {
+ byte value = array.getData().getByte(index);
+ columnEntry.setLiveVal(VByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ } else if (liveData instanceof VUByteArray array) {
+ byte value = array.getData().getByte(index);
+ columnEntry.setLiveVal(VUByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay()));
+ }
+ }
+ else if (liveData instanceof VBooleanArray array) {
+ ListBoolean listBoolean = array.getData();
+ boolean value = listBoolean.getBoolean(index);
+ columnEntry.setLiveVal(VBoolean.of(value, array.getAlarm(), array.getTime()));
+ } else if (liveData instanceof VEnumArray array) {
+ List enumValues = array.getData();
+ columnEntry.setLiveVal(VString.of(enumValues.get(index), array.getAlarm(), array.getTime()));
+ } else if (liveData instanceof VStringArray array) {
+ List stringValues = array.getData();
+ columnEntry.setLiveVal(VString.of(stringValues.get(index), array.getAlarm(), array.getTime()));
+ }
+ addRow(index, columnEntries, columnEntry);
+ }
+ }
+ // Hard coded column count until we support VTable
+ dimensionLiveProperty.set(liveDataArraySize + " x 1");
+ computeNonEqualCount();
+ }
+
+ }
+
+ private void parseAndUpdateThreshold(String value) {
+ thresholdSpinner.getEditor().getStyleClass().remove("input-error");
+ thresholdSpinner.setTooltip(null);
+ try {
+ double parsedNumber = Double.parseDouble(value.trim());
+ updateThreshold(parsedNumber);
+ } catch (Exception e) {
+ thresholdSpinner.getEditor().getStyleClass().add("input-error");
+ thresholdSpinner.setTooltip(new Tooltip(Messages.toolTipMultiplierSpinner));
+ }
+ }
+
+ /**
+ * Computes thresholds on the individual elements. The threshold is used to indicate that a delta value within threshold
+ * should not decorate the delta column.
+ *
+ * @param threshold Threshold in percent
+ */
+ private void updateThreshold(double threshold) {
+ double ratio = threshold / 100;
+
+ comparisonTable.getItems().forEach(comparisonData -> {
+ comparisonData.setThreshold(ratio);
+ });
+
+ computeNonEqualCount();
+ }
+
+ private void computeNonEqualCount(){
+ AtomicInteger nonEqualCount = new AtomicInteger(0);
+ comparisonTable.getItems().forEach(comparisonData -> {
+ comparisonData.getColumnEntries().forEach(columnEntry -> {
+ if(!Utilities.areValuesEqual(columnEntry.liveValueProperty().get(), columnEntry.storedValueProperty().get(), columnEntry.getDelta().get().threshold)){
+ nonEqualCount.incrementAndGet();
+ }
+ });
+ });
+
+ nonEqualCountProperty.set(nonEqualCount.toString());
+ }
+}
diff --git a/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties
index 7371d54ca7..12f05b08bb 100644
--- a/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties
+++ b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties
@@ -15,6 +15,7 @@ cancel=Cancel
cannotCompareHeader=No snapshot data available for comparison.
cannotCompareTitle=Cannot Compare
choose=Choose
+clickToCompare=Click to compare
closeConfigurationWarning=Save&restore configuration modified, but not saved. Do you wish to continue?
closeCompositeSnapshotWarning=Composite snapshot modified, but not saved. Do you wish to continue?
closeSnapshotWarning=Save&restore snapshot created or modified, but not saved. Do you wish to continue?
@@ -29,6 +30,8 @@ copy=Copy
createdDate=Created
createLogEntry=Create Log Entry
createLogEntryToolTip=Create a log entry when snapshot has been saved or restored
+comparisonDialogLunchError=Failed to create comparison dialog
+comparisonDialogTitle=Comparing array/table data
compositeSnapshotName=Composite Snapshot Name
configurationLocation=Location
configurationName=Configuration Name
@@ -71,6 +74,8 @@ deleteFilter=Delete Filter
deleteFilterFailed=Failed to delete filter
description=Description
descriptionHint=Specify non-empty description
+dimensionLive=Dimension Live
+dimensionStored=Dimension Stored
duplicatePVNamesAdditionalItems={0} additional PV names
duplicatePVNamesCheckFailed=Cannot check if snapshots may be added, remote service off-line?
duplicatePVNamesFoundInSelection=Cannot add selected snapshots, duplicate PV names found
@@ -124,6 +129,7 @@ jmasarServiceUnavailable=Save-and-restore service unavailable!
labelMultiplier=Restore with scale
labelThreshold=\u0394 Threshold (%)
lastModifiedDate=Last Modified
+live=Live
liveReadbackVsSetpoint=Live Readback\n(? Live Setpoint)
liveSetpoint=Live Setpoint
logAction=Create Log Entry
@@ -141,6 +147,7 @@ noValueAvailable=No Value Available
help=Help
hitsPerPage=Hits per page
nodeSelectionForConfiguration=Select Node
+nonEqualCount=Non-equal count
openResourceFailedTitle=Unable To Open
openResourceFailedHeader=Node with id "{0}" does not exist
openSearchView=Open Search View
@@ -216,6 +223,7 @@ snapshotLocation=Location
snapshotName=Name
snapshotNameFieldHint=Enter a name (case-sensitive)
status=Status
+stored=Stored
storedReadbackValue=Stored Readback Value
storedValues=Stored Setpoints
tableColumnAlarmSeverity=Alarm Severity
diff --git a/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonView.fxml b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonView.fxml
new file mode 100644
index 0000000000..b0d0e9cb9b
--- /dev/null
+++ b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonView.fxml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+