From e8fb55f9e63e8d1a3105c8f72b1056ea2ad363dd Mon Sep 17 00:00:00 2001 From: wmliu Date: Fri, 7 Nov 2025 13:44:46 -0600 Subject: [PATCH 01/83] Modified the behavior of spinner widget to react differently with different key combinations arrow up/down: change by 1 increment page up/down: change by 10 increment Alt + arrow up/down change by 5 increment Alt + page up/down change by 50 increment --- .../javafx/widgets/SpinnerRepresentation.java | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) 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..931379c21a 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 UP: + if (!active) { + if(event.isAltDown()) { + spinner.getValueFactory().increment(5); + } + else { + spinner.getValueFactory().increment(1); + } + } + break; case PAGE_UP: - if (!active) - spinner.getValueFactory().increment(1); + if (!active) { + if(event.isAltDown()) { + spinner.getValueFactory().increment(50); + } + else { + spinner.getValueFactory().increment(10); + } + } break; case DOWN: + if (!active) { + if(event.isAltDown()) { + spinner.getValueFactory().decrement(5); + } + else { + spinner.getValueFactory().decrement(1); + } + } + break; case PAGE_DOWN: - if (!active) - spinner.getValueFactory().decrement(1); + if (!active) { + if(event.isAltDown()) { + spinner.getValueFactory().decrement(50); + } + else { + spinner.getValueFactory().decrement(10); + } + } break; + case ALT: + setActive(false); + break; default: // Any other key results in active state setActive(true); From b465ebc72c3480d0f70f85e01638a08788a24d39 Mon Sep 17 00:00:00 2001 From: Minijackson Date: Fri, 26 Sep 2025 14:54:01 +0200 Subject: [PATCH 02/83] docs: fix warnings --- app/display/editor/doc/dynamic.rst | 3 +-- app/display/editor/doc/macros.rst | 2 +- app/logbook/olog/ui/doc/index.rst | 4 ++++ app/save-and-restore/app/doc/index.rst | 10 ++++++---- core/formula/doc/index.rst | 3 ++- docs/source/converter.rst | 4 ++-- docs/source/convertor.rst | 4 ++-- docs/source/trouble_shooting.rst | 8 ++++---- services/save-and-restore/doc/index.rst | 8 ++++---- 9 files changed, 26 insertions(+), 20 deletions(-) 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/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/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.: ``![alt-text](https://foo.com/bar.jpg)``. 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/index.rst b/app/save-and-restore/app/doc/index.rst index f826a173fc..1fbdb5171f 100644 --- a/app/save-and-restore/app/doc/index.rst +++ b/app/save-and-restore/app/doc/index.rst @@ -316,12 +316,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/core/formula/doc/index.rst b/core/formula/doc/index.rst index 811412cf51..bf354a1afa 100644 --- a/core/formula/doc/index.rst +++ b/core/formula/doc/index.rst @@ -6,7 +6,8 @@ A prefix of the form "=xx(..)" is typically used to select the formula. Operators ----- +--------- + You can use the following operators : | **Arithmetic and maths operators :** diff --git a/docs/source/converter.rst b/docs/source/converter.rst index dfe7c9329f..200355de3a 100644 --- a/docs/source/converter.rst +++ b/docs/source/converter.rst @@ -13,7 +13,7 @@ Table: + Converter application Description ------------- +----------- AdvancedConverter is a tool to convert massively and recursively CSS OPI to Phoebus BOB files. It will automatically convert the widgets and their properties from the legacy file format. @@ -42,7 +42,7 @@ Exemples : **-main org.csstudio.display.builder.model.AdvancedConverter input/path/to/folder** Converter application ----------------------- +--------------------- Located in *Utility -> OPI converter*, it will generate a pop up window. In this pop up, you can choose a input file or folder with the Browse button in the input section. In a similar way, you can choose or not a output folder. To run the conversion you need to press the run button. diff --git a/docs/source/convertor.rst b/docs/source/convertor.rst index 7c4d360c27..9318f05152 100644 --- a/docs/source/convertor.rst +++ b/docs/source/convertor.rst @@ -13,7 +13,7 @@ Table: + Converter application Description ------------- +----------- AdvancedConverter is a tool to convert massively and recursively CSS OPI to Phoebus BOB files. It will automatically convert the widgets and their properties from the legacy file format. @@ -42,7 +42,7 @@ Exemples : **-main org.csstudio.display.builder.model.AdvancedConverter input/path/to/folder** Converter application ----------------------- +--------------------- Located in *Utility -> OPI converter*, it will generate a pop up window. In this pop up, you can choose a input file or folder with the Browse button in the input section. In a similar way, you can choose or not a output folder. To run the conversion you need to press the run button. diff --git a/docs/source/trouble_shooting.rst b/docs/source/trouble_shooting.rst index 6370930b40..dfeda96851 100644 --- a/docs/source/trouble_shooting.rst +++ b/docs/source/trouble_shooting.rst @@ -27,13 +27,13 @@ Impossible to run Phoebus under linux | If you get the following error message: -.. code-block:: kmsg - - /bin/java : permission denied +.. code-block:: + + /bin/java : permission denied | or -.. code-block:: kmsg +.. code-block:: Error initializing QuantumRenderer: no suitable pipeline found java.lang.RuntimeException: java.lang.RuntimeException: Error initializing QuantumRenderer: no suitable pipeline found diff --git a/services/save-and-restore/doc/index.rst b/services/save-and-restore/doc/index.rst index 41a3f05bc3..80ea893c00 100644 --- a/services/save-and-restore/doc/index.rst +++ b/services/save-and-restore/doc/index.rst @@ -135,7 +135,7 @@ Body: .. code-block:: JSON - ["nodeId-1", "nodeId-2",..., "nodeId-N"] + ["nodeId-1", "nodeId-2", "...", "nodeId-N"] Return: Details of the nodes listed as unique node ids in the request body. @@ -207,7 +207,7 @@ updated. Delete nodes """""""""""" -**.../node +**.../node** Method: DELETE @@ -215,7 +215,7 @@ Caller must specify a body, which is a list of the unique ids of the nodes subje .. code-block:: JSON - ["id_1", "id_2",...., "id_n"] + ["id_1", "id_2", "...", "id_n"] Note that deletion is recursive and non-reversible: @@ -1051,7 +1051,7 @@ Authorization uses a role-based approach like so: * Save-and-restore role "sar-admin": no restrictions Authentication endpoint -""""""""""""""""""""""" +----------------------- A client can may use the /login endpoint to check if user is authenticated: From 200ea9d2a3dcf0c19417b563c3d26a3af7fedd3c Mon Sep 17 00:00:00 2001 From: Minijackson Date: Wed, 12 Nov 2025 09:08:19 +0100 Subject: [PATCH 03/83] databrowser-timescale: add index.rst Else, the app documentation wouldn't be included in Phoebus' documentation --- app/databrowser-timescale/doc/index.rst | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 app/databrowser-timescale/doc/index.rst 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: + + * From 357ddde14471f5741c93dc222e84dc35f4444dcd Mon Sep 17 00:00:00 2001 From: Minijackson Date: Wed, 12 Nov 2025 09:11:14 +0100 Subject: [PATCH 04/83] display/editor: add missing documents to the doc toctree --- app/display/editor/doc/index.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 2d18a934f6ba2e09c24e3e352a5eba599bb39c2b Mon Sep 17 00:00:00 2001 From: shroffk Date: Tue, 25 Nov 2025 11:11:03 -0500 Subject: [PATCH 05/83] preserve the selection for the alarm tree --- .../alarm/ui/tree/AlarmTreeView.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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..f8f8e64086 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; @@ -127,6 +129,9 @@ public class AlarmTreeView extends BorderPane implements AlarmClientListener /** Is change indicator shown, and future been submitted to clear it? */ private final AtomicReference> ongoing_change = new AtomicReference<>(); + /** A flag used to help reserve selections during updates, it flags if any of the selections are to be updated */ + private final AtomicBoolean selectionChanged = new AtomicBoolean(false); + /** Clear the change indicator */ private final Runnable clear_change_indicator = () -> Platform.runLater(() -> @@ -529,6 +534,11 @@ private void performUpdates() items_to_update.clear(); } + // Remember selection + final ObservableList>> updatedSelectedItems = + FXCollections.observableArrayList(tree_view.getSelectionModel().getSelectedItems()); + selectionChanged.set(false); + // 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 +562,11 @@ 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); + selectionChanged.set(true); + } // Move child links to new item final ArrayList>> children = new ArrayList<>(view_item.getChildren()); view_item.getChildren().clear(); @@ -561,6 +576,12 @@ private void performUpdates() path2view.put(value.getPathName(), update); parent.getChildren().set(index, update); } + // Restore selection + if (selectionChanged.get()) { + tree_view.getSelectionModel().clearSelection(); + updatedSelectedItems.forEach(item -> tree_view.getSelectionModel().select(item)); + selectionChanged.set(false); + } } /** Context menu, details depend on selected items */ From 4356591578ad6ac22bbd60fd0664233f05ca862d Mon Sep 17 00:00:00 2001 From: shroffk Date: Tue, 25 Nov 2025 11:20:11 -0500 Subject: [PATCH 06/83] remove the flag since it is inconsistent --- .../applications/alarm/ui/tree/AlarmTreeView.java | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) 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 f8f8e64086..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 @@ -129,9 +129,6 @@ public class AlarmTreeView extends BorderPane implements AlarmClientListener /** Is change indicator shown, and future been submitted to clear it? */ private final AtomicReference> ongoing_change = new AtomicReference<>(); - /** A flag used to help reserve selections during updates, it flags if any of the selections are to be updated */ - private final AtomicBoolean selectionChanged = new AtomicBoolean(false); - /** Clear the change indicator */ private final Runnable clear_change_indicator = () -> Platform.runLater(() -> @@ -537,7 +534,6 @@ private void performUpdates() // Remember selection final ObservableList>> updatedSelectedItems = FXCollections.observableArrayList(tree_view.getSelectionModel().getSelectedItems()); - selectionChanged.set(false); // How to update alarm tree cells when data changed? // `setValue()` with a truly new value (not 'equal') should suffice, @@ -565,7 +561,6 @@ private void performUpdates() if (updatedSelectedItems.contains(view_item)) { updatedSelectedItems.remove(view_item); updatedSelectedItems.add(update); - selectionChanged.set(true); } // Move child links to new item final ArrayList>> children = new ArrayList<>(view_item.getChildren()); @@ -577,11 +572,8 @@ private void performUpdates() parent.getChildren().set(index, update); } // Restore selection - if (selectionChanged.get()) { - tree_view.getSelectionModel().clearSelection(); - updatedSelectedItems.forEach(item -> tree_view.getSelectionModel().select(item)); - selectionChanged.set(false); - } + tree_view.getSelectionModel().clearSelection(); + updatedSelectedItems.forEach(item -> tree_view.getSelectionModel().select(item)); } /** Context menu, details depend on selected items */ From ace4905945cb0505c9aef8c27ad4ab0af8014ec7 Mon Sep 17 00:00:00 2001 From: lcaouen Date: Fri, 21 Nov 2025 10:07:31 +0100 Subject: [PATCH 07/83] generates swagger documentation during the maven build process Swagger documentation is then included and used in the Phoebus documentation using a safe directive --- docs/source/_ext/safe_openapi.py | 37 ++++++++++++++++ docs/source/conf.py | 2 + services/alarm-logger/doc/index.rst | 6 +++ services/alarm-logger/pom.xml | 43 +++++++++++++++++++ .../alarm/logging/rest/SearchController.java | 2 +- 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 docs/source/_ext/safe_openapi.py diff --git a/docs/source/_ext/safe_openapi.py b/docs/source/_ext/safe_openapi.py new file mode 100644 index 0000000000..ff55d55862 --- /dev/null +++ b/docs/source/_ext/safe_openapi.py @@ -0,0 +1,37 @@ +import os +from docutils import nodes +from docutils.parsers.rst import Directive +from sphinx.util.nodes import nested_parse_with_titles + +class SafeOpenApiDirective(Directive): + """ + Usage : + .. safe_openapi:: path/to/openapi.yaml + """ + required_arguments = 1 + + def run(self): + env = self.state.document.settings.env + docdir = os.path.dirname(env.doc2path(env.docname)) + + # Argument from the directive + openapi_path = self.arguments[0] + + # Find absolute path + full_path = os.path.normpath(os.path.join(docdir, openapi_path)) + + if os.path.exists(full_path): + # add the normal `openapi` directive + node = nodes.paragraph() + directive_text = f".. openapi:: {openapi_path}\n" + self.state_machine.insert_input([directive_text], self.state_machine.input_lines.source(0)) + return [] + else: + # add file not found + warning = nodes.paragraph(text=f"OpenAPI file {openapi_path} not found") + return [warning] + + +def setup(app): + app.add_directive("safe_openapi", SafeOpenApiDirective) + return {"version": "1.0"} diff --git a/docs/source/conf.py b/docs/source/conf.py index 600ab1a195..dbef061c24 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -30,8 +30,10 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ + "sphinxcontrib.openapi", "myst_parser", "preferences_listing", + "safe_openapi" ] # Add any paths that contain templates here, relative to this directory. diff --git a/services/alarm-logger/doc/index.rst b/services/alarm-logger/doc/index.rst index 12a6bfa5f0..635384a036 100644 --- a/services/alarm-logger/doc/index.rst +++ b/services/alarm-logger/doc/index.rst @@ -47,4 +47,10 @@ The automatic purge is run using a cron expression defined in preference ``purge An Elasticsearch index is considered eligible for deletion if the last inserted message date is before current time minus the number of days computed from ``date_span_units`` and ``retain_indices_count``. +*** +API +*** +.. safe_openapi:: ../../../../../services/alarm-logger/target/spec-open-api.json + + .. _SpringDocumentation: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/support/CronExpression.html \ No newline at end of file diff --git a/services/alarm-logger/pom.xml b/services/alarm-logger/pom.xml index 0e6b10b68a..5980d534f9 100644 --- a/services/alarm-logger/pom.xml +++ b/services/alarm-logger/pom.xml @@ -173,6 +173,49 @@ + + + io.github.kbuntrock + openapi-maven-plugin + 0.0.28 + + + documentation + + documentation + + + + + + + SPRING_MVC + json + + + RestController + + + + + + + + src/main/java + + + + + + + + org.phoebus.alarm.logging + + + + + + diff --git a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/rest/SearchController.java b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/rest/SearchController.java index 9ad6991ce7..771a594186 100644 --- a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/rest/SearchController.java +++ b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/rest/SearchController.java @@ -103,7 +103,7 @@ public List search(@Parameter(hidden = true) @RequestParam Map< @Operation(summary = "Search alarms by PV name") @RequestMapping(value = "/search/alarm/pv/{pv}", method = RequestMethod.GET) - public List searchPv(@Parameter(description = "PV name") @PathVariable String pv) { + public List searchPv(@Parameter(name="pv", description = "PV name") @PathVariable String pv) { Map searchParameters = new HashMap<>(); searchParameters.put("pv", pv); List result = AlarmLogSearchUtil.search(ElasticClientHelper.getInstance().getClient(), searchParameters); From 00edc4525ff5bbbf1a112c2a33dd692d4497d7a2 Mon Sep 17 00:00:00 2001 From: lcaouen Date: Thu, 27 Nov 2025 16:53:55 +0100 Subject: [PATCH 08/83] show in red incorrect properties in settings.ini --- .../preferences/PropertyPreferenceWriter.java | 123 ++++++++++++++++-- .../java/org/phoebus/ui/help/OpenAbout.java | 12 +- 2 files changed, 118 insertions(+), 17 deletions(-) diff --git a/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java b/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java index 0f39f4392e..7ac59d71a0 100644 --- a/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java +++ b/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java @@ -7,10 +7,25 @@ *******************************************************************************/ package org.phoebus.framework.preferences; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.StringTokenizer; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; import java.util.prefs.Preferences; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** Write preferences in property file format * @author Kay Kasemir @@ -33,34 +48,116 @@ public static void save(final OutputStream stream) throws Exception { try ( + final OutputStreamWriter out = new OutputStreamWriter(stream); ) { - out.append("# Preference settings\n"); - out.append("# Format:\n"); - out.append("# the.package.name/key=value\n"); - listSettings(out, Preferences.userRoot()); - out.append("# End.\n"); + out.append("# Preference settings
\n"); + out.append("# Format:
\n"); + out.append("# the.package.name/key=value
\n"); + listSettings(getAllPropertyKeys(), out, Preferences.userRoot()); + out.append("# End.
\n"); out.flush(); } } - private static void listSettings(final Writer out, final Preferences node) throws Exception + private static void listSettings(Map allKeysWithPackages, final Writer out, final Preferences node) throws Exception { for (String key : node.keys()) - formatSetting(out, node, key); + formatSetting(allKeysWithPackages, out, node, key); for (String child : node.childrenNames()) - listSettings(out, node.node(child)); + listSettings(allKeysWithPackages, out, node.node(child)); } - private static void formatSetting(final Writer out, final Preferences node, final String key) throws Exception + private static void formatSetting(Map allKeysWithPackages, final Writer out, final Preferences node, final String key) throws Exception { final String path = node.absolutePath(); - out.append(path.substring(1).replace('/', '.')) - .append('/') - .append(key) + String fullKey = path.substring(1).replace('/', '.') + '/' + key; + String keyFound = allKeysWithPackages.get(fullKey); + boolean bNotFound = keyFound == null ? true : false; + if (bNotFound) out.append("
"); + out.append(fullKey) .append('=') .append(node.get(key, "")) - .append('\n'); + .append("
\n"); + if (bNotFound) out.append("
"); } + + private static Map getAllPropertyKeys() throws Exception + { + Map allKeysWithPackages = new HashMap<>(); + + String classpath = System.getProperty("java.class.path"); + StringTokenizer tokenizer = new StringTokenizer(classpath, System.getProperty("path.separator")); + + while (tokenizer.hasMoreTokens()) { + String path = tokenizer.nextToken(); + File file = new File(path); + + if (path.endsWith(".jar")) { + try (JarFile jarFile = new JarFile(file)) { + Enumeration entries = jarFile.entries(); + + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String entryName = entry.getName(); + + if (entryName.endsWith("preferences.properties")) { + parsePropertiesWithPackage( + jarFile.getInputStream(entry), + entryName, + allKeysWithPackages + ); + } + } + } catch (IOException e) { + System.err.println("Error opening JAR : " + path); + e.printStackTrace(); + } + } + } + + return allKeysWithPackages; + } + + private static void parsePropertiesWithPackage( + InputStream inputStream, + String fileName, + Map allKeysWithPackages + ) { + Properties props = new Properties(); + String packageName = null; + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + StringBuilder content = new StringBuilder(); + + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.startsWith("#") && line.contains("Package")) { + // Find package name + Pattern pattern = Pattern.compile("#\\s*Package\\s+([^\\s]+)"); + Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + packageName = matcher.group(1); + } + } else if (!line.startsWith("#")) { + content.append(line).append("\n"); + } + } + + if (content.length() > 0) { + props.load(new ByteArrayInputStream(content.toString().getBytes())); + } + + // properties found + for (String key : props.stringPropertyNames()) { + String prefixedKey = (packageName != null) ? packageName + "/" + key : key; + allKeysWithPackages.put(prefixedKey, props.getProperty(key)); + } + } catch (IOException e) { + System.err.println("Error when reading file " + fileName); + e.printStackTrace(); + } + } } diff --git a/core/ui/src/main/java/org/phoebus/ui/help/OpenAbout.java b/core/ui/src/main/java/org/phoebus/ui/help/OpenAbout.java index 0add9484a5..f9c90c5726 100644 --- a/core/ui/src/main/java/org/phoebus/ui/help/OpenAbout.java +++ b/core/ui/src/main/java/org/phoebus/ui/help/OpenAbout.java @@ -42,6 +42,7 @@ import javafx.scene.image.Image; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; +import javafx.scene.web.WebView; /** Menu entry to open 'about' * @author Kay Kasemir @@ -204,12 +205,15 @@ private Node createDetailSection() logger.log(Level.WARNING, "Cannot list preferences", ex); } - area = new TextArea(prefs_buf.toString()); - area.setEditable(false); + WebView webView = new WebView(); + String content = ""; + content += prefs_buf.toString(); + content += ""; + webView.getEngine().loadContent(content); - VBox.setVgrow(area, Priority.ALWAYS); + VBox.setVgrow(webView, Priority.ALWAYS); - final Tab prefs = new Tab(Messages.HelpAboutPrefs, area); + final Tab prefs = new Tab(Messages.HelpAboutPrefs, webView); final TabPane tabs = new TabPane(apps, envs, props, prefs); return tabs; From aeb12059a22d974e0908adc36875cc6fb7a6cfe2 Mon Sep 17 00:00:00 2001 From: lcaouen Date: Tue, 30 Sep 2025 13:28:56 +0200 Subject: [PATCH 09/83] add validation when loosing focus --- .../alarm/ui/tree/TitleDetailDelayTable.java | 105 +++++++++++++----- .../alarm/ui/tree/TitleDetailTable.java | 17 ++- .../ui/tree/ValidatingTextFieldTableCell.java | 47 ++++++++ 3 files changed, 129 insertions(+), 40 deletions(-) create mode 100644 app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ValidatingTextFieldTableCell.java 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); + } +} From 29297a8c6e76f98e44783411c06a312d4c83e07f Mon Sep 17 00:00:00 2001 From: lcaouen Date: Fri, 14 Nov 2025 15:48:15 +0100 Subject: [PATCH 10/83] add parameters to generate the file from the source files. generation from jar still working --- phoebus-product/create_settings_template.py | 174 +++++++++++++------- 1 file changed, 112 insertions(+), 62 deletions(-) diff --git a/phoebus-product/create_settings_template.py b/phoebus-product/create_settings_template.py index 8f9e65fe67..5bf7ef914f 100644 --- a/phoebus-product/create_settings_template.py +++ b/phoebus-product/create_settings_template.py @@ -6,84 +6,134 @@ import sys -def create_settings_template(product_location: str, include_comments: bool, verbose: bool) -> None: +def remove_duplicate_properties(prop_files): + unique = {} + for path in prop_files: + filename = os.path.basename(path) + if filename not in unique: + unique[filename] = path + return list(unique.values()) + + +def get_settings_from_properties( + out_f, tmp_zip_dir: str, include_comments: bool, verbose: bool +) -> None: + # find all *preferences.properties files + pattern = tmp_zip_dir + "/**/*preferences.properties" + prop_files = glob.glob(pattern, recursive=True) + prop_files_unique = remove_duplicate_properties(prop_files) + + package_str = "" + for prop_file in prop_files_unique: + if verbose: + print("+ {}".format(prop_file).replace("/./tmp-zip/", "/")) + with open(prop_file, "r") as file: + lines = file.readlines() + for line in lines: + line = line.strip() + if line.startswith("# Package "): + package_str = line[10:].strip() + # print package name with number signs above and below + if include_comments: + out_f.write("\n{0}\n{1}\n{0}\n".format("#" * (len(line)), line)) + else: + out_f.write("\n{0}\n{1}\n{0}\n\n".format("#" * (len(line)), line)) + # if verbose: + # print("| {} ({})".format(" " * len(jar_file), package_str)) + elif "--------" in line: + continue + # assume equal sign means this is a property + elif "=" in line: + if line[0] == "#": + if include_comments: + out_f.write("# {}/{}\n".format(package_str, line[1:].strip())) + else: + out_f.write("# {}/{}\n".format(package_str, line)) + # a few pva properties don't have equal sign so this covers those + elif line != "" and line[0] != "#": + out_f.write("# {}/{}\n".format(package_str, line)) + else: + if include_comments: + out_f.write(line + "\n") + + +def create_settings_template( + product_location: str, + include_comments: bool, + verbose: bool, + properties_directory: str, +) -> None: """ Create complete list of settings for settings.ini file :param product_location: Location of the product jar files to check for *preferences.properties files :param include_comments: Option to include comments for each setting in output file :param verbose: Verbose operation + :param properties_directory: Using src directory instead of jar files """ - # find all jar files so we can unzip jar and find preference files - jar_file_list = glob.glob(product_location + "/*.jar") - if len(jar_file_list) <= 0: - sys.stderr.write("No *.jar files found in '{}'\n".format(product_location)) - sys.stderr.write("Need to build sources?\n") - sys.exit(-1) - jar_file_list = sorted(jar_file_list, key=str.lower) - - # temp directory to hold unzipped jar file contents (deleted at end of script) - tmp_zip_dir = "./tmp-zip" - output_file = "settings_template.ini" - out_f = open(output_file, 'w') print("Creating settings_template.ini file...") + output_file = "settings_template.ini" + out_f = open(output_file, "w") + out_f.write( + "# Complete List of Available Preference Properties (Created by create_settings_template.py)\n" + ) - out_f.write("# Complete List of Available Preference Properties (Created by create_settings_template.py)\n") + if not properties_directory: + print("Parsing properties in jar files") + # find all jar files so we can unzip jar and find preference files + jar_file_list = glob.glob(product_location + "/*.jar") + if len(jar_file_list) <= 0: + sys.stderr.write("No *.jar files found in '{}'\n".format(product_location)) + sys.stderr.write("Need to build sources?\n") + sys.exit(-1) + jar_file_list = sorted(jar_file_list, key=str.lower) - for jar_file in jar_file_list: - if verbose: - print("| {}".format(jar_file)) - if not os.path.isdir(tmp_zip_dir): - os.makedirs(tmp_zip_dir) - with zipfile.ZipFile(jar_file, 'r') as zip_ref: - zip_ref.extractall(tmp_zip_dir) - # find all *preference.properties files - prop_files = glob.glob(tmp_zip_dir + "/*preferences.properties") - - package_str = "" - for prop_file in prop_files: + # temp directory to hold unzipped jar file contents (deleted at end of script) + tmp_zip_dir = "./tmp-zip" + + for jar_file in jar_file_list: if verbose: - print("+ {}/{}".format(jar_file, prop_file).replace("/./tmp-zip/", "/")) - with open(prop_file, 'r') as file: - lines = file.readlines() - for line in lines: - line = line.strip() - if line.startswith("# Package "): - package_str = line[10:].strip() - # print package name with number signs above and below - if include_comments: - out_f.write("\n{0}\n{1}\n{0}\n".format("#"*(len(line)), line)) - else: - out_f.write("\n{0}\n{1}\n{0}\n\n".format("#"*(len(line)), line)) - if verbose: - print("| {} ({})".format(" " * len(jar_file), package_str)) - elif "--------" in line: - continue - # assume equal sign means this is a property - elif "=" in line: - if line[0] == "#": - if include_comments: - out_f.write("# {}/{}\n".format(package_str, line[1:].strip())) - else: - out_f.write("# {}/{}\n".format(package_str, line)) - # a few pva properties don't have equal sign so this covers those - elif line != "" and line[0] != "#": - out_f.write("# {}/{}\n".format(package_str, line)) - else: - if include_comments: - out_f.write(line + "\n") + print("| {}".format(jar_file)) + if not os.path.isdir(tmp_zip_dir): + os.makedirs(tmp_zip_dir) + with zipfile.ZipFile(jar_file, "r") as zip_ref: + zip_ref.extractall(tmp_zip_dir) + + get_settings_from_properties(out_f, tmp_zip_dir, include_comments, verbose) + + # remove temp directory + shutil.rmtree(tmp_zip_dir) + else: + print(f"Parsing properties in : {args.directory}") + get_settings_from_properties( + out_f, properties_directory, include_comments, verbose + ) - # remove temp directory - shutil.rmtree(tmp_zip_dir) print("Creation complete") -parser = argparse.ArgumentParser(description="Create template of settings.ini with all available settings") -parser.add_argument("product", type=str, nargs='?', default="./target/lib", help="Location of product jars. Defaults to ./target/lib") -parser.add_argument("-c", "--comments", action="store_true", help="Include setting comments in file") +parser = argparse.ArgumentParser( + description="Create template of settings.ini with all available settings" +) +parser.add_argument( + "product", + type=str, + nargs="?", + default="./target/lib", + help="Location of product jars. Defaults to ./target/lib", +) +parser.add_argument( + "-c", "--comments", action="store_true", help="Include setting comments in file" +) parser.add_argument("-v", "--verbose", action="store_true", help="Verbose operation") - +parser.add_argument( + "-d", + "--directory", + type=str, + help="Specify the source directory instead of using compiled jar", +) args = parser.parse_args() -create_settings_template(args.product, args.comments, args.verbose) +print(args.directory) +create_settings_template(args.product, args.comments, args.verbose, args.directory) From e08d6f12bbd66dfe6303d351c6f080fc911f38f8 Mon Sep 17 00:00:00 2001 From: lcaouen Date: Thu, 27 Nov 2025 14:50:15 +0100 Subject: [PATCH 11/83] remove CI : swagger.json is now generated during maven build --- .github/workflows/build_swagger.yml | 31 ----------------------------- docs/pyproject.toml | 1 + 2 files changed, 1 insertion(+), 31 deletions(-) delete mode 100644 .github/workflows/build_swagger.yml 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/docs/pyproject.toml b/docs/pyproject.toml index d96626d560..15e23d781f 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "sphinx_rtd_theme>=3.0", "myst-parser>=4.0", "setuptools", + "sphinxcontrib.openapi" ] [tool.pixi.workspace] From 35093e4003119e0acb7bb55a8cfd23d9f2d4ac74 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Fri, 28 Nov 2025 18:42:00 +0100 Subject: [PATCH 12/83] Setup base APIs to show dialog comparing stored array and live array --- .../ui/snapshot/compare/ColumnEntry.java | 23 ++++ .../ui/snapshot/compare/ComparisonData.java | 41 ++++++ .../ui/snapshot/compare/ComparisonDialog.java | 34 +++++ .../TableComparisonViewController.java | 117 ++++++++++++++++++ .../snapshot/compare/TableComparisonView.fxml | 28 +++++ .../compare/ComparisonDialogDemo.java | 33 +++++ 6 files changed, 276 insertions(+) create mode 100644 app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ColumnEntry.java create mode 100644 app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonData.java create mode 100644 app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialog.java create mode 100644 app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonViewController.java create mode 100644 app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonView.fxml create mode 100644 app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java 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..0add53ebbf --- /dev/null +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ColumnEntry.java @@ -0,0 +1,23 @@ +/* + * 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; + +public class ColumnEntry { + + private final ObjectProperty snapshotVal = new SimpleObjectProperty<>(this, "snapshotValue", null); + private final ObjectProperty delta = new SimpleObjectProperty<>(this, "delta", null); + private final ObjectProperty liveVal = new SimpleObjectProperty<>(this, "liveValue", null); + + public ColumnEntry(T snapshotVal){ + this.snapshotVal.set(snapshotVal); + } + + public ObjectProperty getSnapshotValue(){ + return snapshotVal; + } +} 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..836c0416f6 --- /dev/null +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonData.java @@ -0,0 +1,41 @@ +/* + * 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.VBooleanArray; +import org.epics.vtype.VByteArray; +import org.epics.vtype.VDoubleArray; +import org.epics.vtype.VFloatArray; +import org.epics.vtype.VIntArray; +import org.epics.vtype.VLongArray; +import org.epics.vtype.VNumberArray; +import org.epics.vtype.VShortArray; +import org.epics.vtype.VTable; +import org.epics.vtype.VType; + +import java.util.ArrayList; +import java.util.List; + +public class ComparisonData { + + private final IntegerProperty index = new SimpleIntegerProperty(this, "index"); + private 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; + } +} 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..d10b3b748c --- /dev/null +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialog.java @@ -0,0 +1,34 @@ +/* + * 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.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; + +public class ComparisonDialog extends Dialog { + + public ComparisonDialog(VType data, String pvName){ + + 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); + } 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..aaa0c381ac --- /dev/null +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonViewController.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.ui.snapshot.compare; + +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.fxml.FXML; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import org.epics.util.array.IteratorNumber; +import org.epics.util.array.ListBoolean; +import org.epics.vtype.VBooleanArray; +import org.epics.vtype.VByteArray; +import org.epics.vtype.VDoubleArray; +import org.epics.vtype.VFloatArray; +import org.epics.vtype.VIntArray; +import org.epics.vtype.VLongArray; +import org.epics.vtype.VNumberArray; +import org.epics.vtype.VShortArray; +import org.epics.vtype.VType; + +import java.util.ArrayList; +import java.util.List; + +public class TableComparisonViewController { + + @SuppressWarnings("unused") + @FXML + private TableView comparisonTable; + + @SuppressWarnings("unused") + @FXML + private TableColumn valueColumn; + + @FXML + public void initialize(){ + comparisonTable.getStylesheets().add(TableComparisonViewController.class.getResource("/save-and-restore-style.css").toExternalForm()); + + valueColumn.setCellValueFactory(cell -> + cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getSnapshotValue()); + } + + public void loadDataAndConnect(VType data, String pvName){ + + if(data instanceof VNumberArray){ + IteratorNumber iteratorNumber = ((VNumberArray)data).getData().iterator(); + List columnEntries = new ArrayList<>(); + int index = 0; + if(data instanceof VDoubleArray){ + while(iteratorNumber.hasNext()){ + double value = iteratorNumber.nextDouble(); + ColumnEntry columnEntry = new ColumnEntry<>(value); + addRow(index, columnEntries, columnEntry); + index++; + } + } + else if(data instanceof VFloatArray){ + while(iteratorNumber.hasNext()){ + float value = iteratorNumber.nextFloat(); + ColumnEntry columnEntry = new ColumnEntry<>(value); + addRow(index, columnEntries, columnEntry); + index++; + } + } + else if(data instanceof VIntArray){ + while(iteratorNumber.hasNext()){ + int value = iteratorNumber.nextInt(); + ColumnEntry columnEntry = new ColumnEntry<>(value); + addRow(index, columnEntries, columnEntry); + index++; + } + } + else if(data instanceof VLongArray){ + while(iteratorNumber.hasNext()){ + long value = iteratorNumber.nextLong(); + ColumnEntry columnEntry = new ColumnEntry<>(value); + addRow(index, columnEntries, columnEntry); + index++; + } + } + else if(data instanceof VShortArray){ + while(iteratorNumber.hasNext()){ + short value = iteratorNumber.nextShort(); + ColumnEntry columnEntry = new ColumnEntry<>(value); + addRow(index, columnEntries, columnEntry); + index++; + } + } + else if(data instanceof VByteArray){ + while(iteratorNumber.hasNext()){ + byte value = iteratorNumber.nextByte(); + ColumnEntry columnEntry = new ColumnEntry<>(value); + addRow(index, columnEntries, columnEntry); + index++; + } + } + else if(data instanceof VBooleanArray){ + ListBoolean listBoolean = ((VBooleanArray)data).getData(); + for(int i = 0; i < listBoolean.size(); i++){ + boolean value = listBoolean.getBoolean(i); + ColumnEntry columnEntry = new ColumnEntry<>(value); + addRow(index, columnEntries, columnEntry); + } + } + } + + } + + private void addRow(int index, List columnEntries, ColumnEntry columnEntry){ + columnEntries.add(columnEntry); + ComparisonData comparisonData = new ComparisonData(index, columnEntries); + comparisonTable.getItems().add(index, comparisonData); + } + + +} 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..6469919b52 --- /dev/null +++ b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonView.fxml @@ -0,0 +1,28 @@ + + + + + + + +
+ + + + + + + + + + + + + + + + +
+
diff --git a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java new file mode 100644 index 0000000000..3e5dc7f5ba --- /dev/null +++ b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.ui.snapshot.compare; + +import javafx.stage.Stage; +import org.epics.util.array.ArrayDouble; +import org.epics.util.array.ListDouble; +import org.epics.vtype.Alarm; +import org.epics.vtype.Display; +import org.epics.vtype.Time; +import org.epics.vtype.VDoubleArray; +import org.phoebus.ui.javafx.ApplicationWrapper; + +public class ComparisonDialogDemo extends ApplicationWrapper { + + public static void main(String[] args){ + launch(ComparisonDialogDemo.class, args); + } + + @Override + public void start(Stage primaryStage) throws Exception { + VDoubleArray vDoubleArray = + VDoubleArray.of(ArrayDouble.of(1.0, 2.0, 3.0, 4.0, 5.0), + Alarm.none(), + Time.now(), + Display.none()); + ComparisonDialog comparisonDialog = + new ComparisonDialog(vDoubleArray, ""); + comparisonDialog.show(); + } +} From f5c5bf0eb18e3e5a7aa13c6d278fd01a00ac120b Mon Sep 17 00:00:00 2001 From: Abraham Wolk Date: Mon, 1 Dec 2025 10:51:28 +0100 Subject: [PATCH 13/83] CSSTUDIO-3597 Create a new instance of macros in EmbeddedDisplayWidget.getEffectiveMacros(). --- .../builder/model/widgets/EmbeddedDisplayWidget.java | 7 +++++++ 1 file changed, 7 insertions(+) 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() { From 2f5314b4163e12ae162cc98877030c1b4dbd7f1c Mon Sep 17 00:00:00 2001 From: georgweiss Date: Mon, 1 Dec 2025 13:25:19 +0100 Subject: [PATCH 14/83] Add pv name to dialog layout --- .../ui/snapshot/compare/ComparisonDialog.java | 3 +++ .../TableComparisonViewController.java | 6 +++++ .../snapshot/compare/TableComparisonView.fxml | 22 ++++++++++++------- .../compare/ComparisonDialogDemo.java | 4 ++-- 4 files changed, 25 insertions(+), 10 deletions(-) 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 index d10b3b748c..53702b4385 100644 --- 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 @@ -6,6 +6,7 @@ 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; @@ -18,6 +19,8 @@ public class ComparisonDialog extends Dialog { public ComparisonDialog(VType data, String pvName){ + getDialogPane().getButtonTypes().addAll(ButtonType.OK); + ResourceBundle resourceBundle = NLS.getMessages(Messages.class); FXMLLoader loader = new FXMLLoader(); loader.setResources(resourceBundle); 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 index aaa0c381ac..4d937f5f70 100644 --- 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 @@ -6,6 +6,7 @@ import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.fxml.FXML; +import javafx.scene.control.Label; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import org.epics.util.array.IteratorNumber; @@ -33,6 +34,9 @@ public class TableComparisonViewController { @FXML private TableColumn valueColumn; + @FXML + private Label pvName; + @FXML public void initialize(){ comparisonTable.getStylesheets().add(TableComparisonViewController.class.getResource("/save-and-restore-style.css").toExternalForm()); @@ -43,6 +47,8 @@ public void initialize(){ public void loadDataAndConnect(VType data, String pvName){ + this.pvName.textProperty().set(pvName); + if(data instanceof VNumberArray){ IteratorNumber iteratorNumber = ((VNumberArray)data).getData().iterator(); List columnEntries = new ArrayList<>(); 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 index 6469919b52..f50e316e2d 100644 --- 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 @@ -1,28 +1,34 @@ + + - - +
- + - - - + + +
+ + +
diff --git a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java index 3e5dc7f5ba..82e88c11ea 100644 --- a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java +++ b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java @@ -22,12 +22,12 @@ public static void main(String[] args){ @Override public void start(Stage primaryStage) throws Exception { VDoubleArray vDoubleArray = - VDoubleArray.of(ArrayDouble.of(1.0, 2.0, 3.0, 4.0, 5.0), + VDoubleArray.of(ArrayDouble.of(5.0, 6.0, 7.0, 8.0, 9.0), Alarm.none(), Time.now(), Display.none()); ComparisonDialog comparisonDialog = - new ComparisonDialog(vDoubleArray, ""); + new ComparisonDialog(vDoubleArray, "MUSIGNY"); comparisonDialog.show(); } } From a557455a6728a70032d99d33ba867d913498e7a6 Mon Sep 17 00:00:00 2001 From: dkastelic Date: Mon, 24 Nov 2025 10:26:06 +0100 Subject: [PATCH 15/83] Port AlarmTreePath unit test from old cs studio --- .../alarm/AlarmTreePathUnitTest.java | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 app/alarm/model/src/test/java/org/phoebus/applications/alarm/AlarmTreePathUnitTest.java 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..c038fb59e3 --- /dev/null +++ b/app/alarm/model/src/test/java/org/phoebus/applications/alarm/AlarmTreePathUnitTest.java @@ -0,0 +1,143 @@ +/******************************************************************************* + * 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 static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +/** 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 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 From a67740248698e94dc4b229de41114a1ec0864d75 Mon Sep 17 00:00:00 2001 From: dkastelic Date: Mon, 1 Dec 2025 11:19:47 +0100 Subject: [PATCH 16/83] Fix bug where moving tree item to unchanged path would delete it --- .../applications/alarm/model/AlarmTreePath.java | 15 +++++++++++++++ .../alarm/ui/tree/MoveTreeItemAction.java | 8 ++++++++ 2 files changed, 23 insertions(+) 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..d9e1d2c0f5 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". @@ -137,4 +138,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/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..ff689a02bd 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; @@ -54,6 +55,13 @@ public MoveTreeItemAction(TreeView> node, prompt = "Invalid path. Try again or cancel"; } + // 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) From 9a11ac7e2470654d05cb4710cdda51d8a8eb0fb8 Mon Sep 17 00:00:00 2001 From: dkastelic Date: Mon, 1 Dec 2025 14:12:44 +0100 Subject: [PATCH 17/83] Update makePath to throw exception --- .../alarm/model/AlarmTreePath.java | 24 +++++++++++++------ .../alarm/model/AlarmTreePathException.java | 7 ++++++ .../alarm/AlarmTreePathUnitTest.java | 13 ++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/AlarmTreePathException.java 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 d9e1d2c0f5..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 @@ -33,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, "\\/")); } @@ -113,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); 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..01232c3f07 --- /dev/null +++ b/app/alarm/model/src/main/java/org/phoebus/applications/alarm/model/AlarmTreePathException.java @@ -0,0 +1,7 @@ +package org.phoebus.applications.alarm.model; + +public class AlarmTreePathException extends IllegalArgumentException { + public AlarmTreePathException(String message) { + super(message); + } +} 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 index c038fb59e3..d374b45530 100644 --- 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 @@ -9,9 +9,11 @@ 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 @@ -32,6 +34,17 @@ public void testPathCheck() 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() { From 03139678c3de7f4e33692c9c25a9dcb10cbc3139 Mon Sep 17 00:00:00 2001 From: dkastelic Date: Mon, 1 Dec 2025 14:13:22 +0100 Subject: [PATCH 18/83] Update UI actions to handle AlarmTreePath exception --- .../applications/alarm/client/AlarmClient.java | 16 ++++------------ .../alarm/ui/tree/AddComponentAction.java | 8 +++++++- .../alarm/ui/tree/DuplicatePVAction.java | 2 +- .../alarm/ui/tree/RenameTreeItemAction.java | 4 ++-- 4 files changed, 14 insertions(+), 16 deletions(-) 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/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/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/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) { From ac1840263781a9f31ca4b7eb8eeb43fb80036880 Mon Sep 17 00:00:00 2001 From: dkastelic Date: Mon, 1 Dec 2025 14:14:19 +0100 Subject: [PATCH 19/83] Update XmlModelReader to handle AlarmTreePathException --- .../alarm/model/xml/XmlModelReader.java | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) 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 From 11a5bd1b96e33de6e22cecad3c8ee7785a59cde1 Mon Sep 17 00:00:00 2001 From: dkastelic Date: Mon, 1 Dec 2025 15:06:43 +0100 Subject: [PATCH 20/83] Document AlarmTreePathException --- .../applications/alarm/model/AlarmTreePathException.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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 index 01232c3f07..06f5dc7247 100644 --- 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 @@ -1,5 +1,13 @@ 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); From ab385da8302931c061bc6364ea48e02df7cd5802 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Mon, 1 Dec 2025 15:11:33 +0100 Subject: [PATCH 21/83] Update comparison table when PV is connected --- .../ui/snapshot/compare/ColumnEntry.java | 12 +- .../TableComparisonViewController.java | 129 ++++++++++++++---- .../snapshot/compare/TableComparisonView.fxml | 2 +- .../compare/ComparisonDialogDemo.java | 7 +- 4 files changed, 116 insertions(+), 34 deletions(-) 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 index 0add53ebbf..098a169aa6 100644 --- 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 @@ -6,12 +6,14 @@ import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; +import org.epics.vtype.VType; +import org.phoebus.core.vtypes.VDisconnectedData; public class ColumnEntry { private final ObjectProperty snapshotVal = new SimpleObjectProperty<>(this, "snapshotValue", null); private final ObjectProperty delta = new SimpleObjectProperty<>(this, "delta", null); - private final ObjectProperty liveVal = new SimpleObjectProperty<>(this, "liveValue", null); + private final ObjectProperty liveVal = new SimpleObjectProperty<>(this, "liveValue", (T) VDisconnectedData.INSTANCE); public ColumnEntry(T snapshotVal){ this.snapshotVal.set(snapshotVal); @@ -20,4 +22,12 @@ public ColumnEntry(T snapshotVal){ public ObjectProperty getSnapshotValue(){ return snapshotVal; } + + public void setLiveVal(T value){ + liveVal.set(value); + } + + public ObjectProperty getLiveValue(){ + return liveVal; + } } 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 index 4d937f5f70..c9be0cf2d3 100644 --- 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 @@ -4,7 +4,9 @@ package org.phoebus.applications.saveandrestore.ui.snapshot.compare; -import javafx.beans.property.ReadOnlyObjectWrapper; + +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; import javafx.fxml.FXML; import javafx.scene.control.Label; import javafx.scene.control.TableColumn; @@ -14,15 +16,23 @@ import org.epics.vtype.VBooleanArray; import org.epics.vtype.VByteArray; import org.epics.vtype.VDoubleArray; +import org.epics.vtype.VEnumArray; import org.epics.vtype.VFloatArray; import org.epics.vtype.VIntArray; import org.epics.vtype.VLongArray; import org.epics.vtype.VNumberArray; import org.epics.vtype.VShortArray; +import org.epics.vtype.VStringArray; import org.epics.vtype.VType; +import org.phoebus.core.vtypes.VDisconnectedData; +import org.phoebus.pv.PV; +import org.phoebus.pv.PVPool; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; public class TableComparisonViewController { @@ -34,90 +44,149 @@ public class TableComparisonViewController { @FXML private TableColumn valueColumn; + @SuppressWarnings("unused") + @FXML + private TableColumn liveValueColumn; + + @SuppressWarnings("unused") @FXML private Label pvName; + private final StringProperty pvNameProperty = new SimpleStringProperty(); + + /** + * The time between updates of dynamic data in the table, in ms. + */ + private static final long TABLE_UPDATE_INTERVAL = 500; + @FXML - public void initialize(){ + public void initialize() { comparisonTable.getStylesheets().add(TableComparisonViewController.class.getResource("/save-and-restore-style.css").toExternalForm()); - + pvName.textProperty().bind(pvNameProperty); valueColumn.setCellValueFactory(cell -> - cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getSnapshotValue()); + cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getSnapshotValue()); + liveValueColumn.setCellValueFactory(cell -> + cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getLiveValue()); } - public void loadDataAndConnect(VType data, String pvName){ + public void loadDataAndConnect(VType data, String pvName) { - this.pvName.textProperty().set(pvName); + pvNameProperty.set(pvName); - if(data instanceof VNumberArray){ - IteratorNumber iteratorNumber = ((VNumberArray)data).getData().iterator(); + if (data instanceof VNumberArray) { + IteratorNumber iteratorNumber = ((VNumberArray) data).getData().iterator(); List columnEntries = new ArrayList<>(); int index = 0; - if(data instanceof VDoubleArray){ - while(iteratorNumber.hasNext()){ + if (data instanceof VDoubleArray) { + while (iteratorNumber.hasNext()) { double value = iteratorNumber.nextDouble(); ColumnEntry columnEntry = new ColumnEntry<>(value); addRow(index, columnEntries, columnEntry); index++; } - } - else if(data instanceof VFloatArray){ - while(iteratorNumber.hasNext()){ + } else if (data instanceof VFloatArray) { + while (iteratorNumber.hasNext()) { float value = iteratorNumber.nextFloat(); ColumnEntry columnEntry = new ColumnEntry<>(value); addRow(index, columnEntries, columnEntry); index++; } - } - else if(data instanceof VIntArray){ - while(iteratorNumber.hasNext()){ + } else if (data instanceof VIntArray) { + while (iteratorNumber.hasNext()) { int value = iteratorNumber.nextInt(); ColumnEntry columnEntry = new ColumnEntry<>(value); addRow(index, columnEntries, columnEntry); index++; } - } - else if(data instanceof VLongArray){ - while(iteratorNumber.hasNext()){ + } else if (data instanceof VLongArray) { + while (iteratorNumber.hasNext()) { long value = iteratorNumber.nextLong(); ColumnEntry columnEntry = new ColumnEntry<>(value); addRow(index, columnEntries, columnEntry); index++; } - } - else if(data instanceof VShortArray){ - while(iteratorNumber.hasNext()){ + } else if (data instanceof VShortArray) { + while (iteratorNumber.hasNext()) { short value = iteratorNumber.nextShort(); ColumnEntry columnEntry = new ColumnEntry<>(value); addRow(index, columnEntries, columnEntry); index++; } - } - else if(data instanceof VByteArray){ - while(iteratorNumber.hasNext()){ + } else if (data instanceof VByteArray) { + while (iteratorNumber.hasNext()) { byte value = iteratorNumber.nextByte(); ColumnEntry columnEntry = new ColumnEntry<>(value); addRow(index, columnEntries, columnEntry); index++; } - } - else if(data instanceof VBooleanArray){ - ListBoolean listBoolean = ((VBooleanArray)data).getData(); - for(int i = 0; i < listBoolean.size(); i++){ + } else if (data instanceof VBooleanArray) { + ListBoolean listBoolean = ((VBooleanArray) data).getData(); + for (int i = 0; i < listBoolean.size(); i++) { boolean value = listBoolean.getBoolean(i); ColumnEntry columnEntry = new ColumnEntry<>(value); addRow(index, columnEntries, columnEntry); } + } else if (data instanceof VEnumArray) { + List enumValues = ((VEnumArray) data).getData(); + for (int i = 0; i < enumValues.size(); i++) { + ColumnEntry columnEntry = new ColumnEntry<>(enumValues.get(i)); + addRow(index, columnEntries, columnEntry); + } } } + connect(); + } - private void addRow(int index, List columnEntries, ColumnEntry columnEntry){ + private void addRow(int index, List columnEntries, ColumnEntry columnEntry) { columnEntries.add(columnEntry); ComparisonData comparisonData = new ComparisonData(index, columnEntries); comparisonTable.getItems().add(index, comparisonData); } + public void connect() { + try { + PV 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); + } + } + private void updateTable(VType liveData) { + if (liveData.equals(VDisconnectedData.INSTANCE)) { + comparisonTable.getItems().forEach(i -> i.getColumnEntries().get(0).setLiveVal(VDisconnectedData.INSTANCE)); + } else { + comparisonTable.getItems().forEach(i -> { + int index = i.indexProperty().get(); + if (liveData instanceof VDoubleArray) { + VDoubleArray array = (VDoubleArray) liveData; + i.getColumnEntries().get(index).setLiveVal(array.getData().getDouble(index)); + } else if (liveData instanceof VIntArray) { + VIntArray array = (VIntArray) liveData; + i.getColumnEntries().get(index).setLiveVal(array.getData().getInt(index)); + } else if (liveData instanceof VLongArray) { + VLongArray array = (VLongArray) liveData; + i.getColumnEntries().get(index).setLiveVal(array.getData().getLong(index)); + } else if (liveData instanceof VFloatArray) { + VFloatArray array = (VFloatArray) liveData; + i.getColumnEntries().get(index).setLiveVal(array.getData().getFloat(index)); + } else if (liveData instanceof VShortArray) { + VShortArray array = (VShortArray) liveData; + i.getColumnEntries().get(index).setLiveVal(array.getData().getShort(index)); + } else if (liveData instanceof VBooleanArray) { + VBooleanArray array = (VBooleanArray)liveData; + i.getColumnEntries().get(index).setLiveVal(array.getData().getBoolean(index)); + } else if (liveData instanceof VEnumArray) { + VEnumArray array = (VEnumArray) liveData; + i.getColumnEntries().get(index).setLiveVal(array.getData().get(index)); + } else if (liveData instanceof VStringArray) { + VStringArray array = (VStringArray) liveData; + i.getColumnEntries().get(index).setLiveVal(array.getData().get(index)); + } + }); + } + } } 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 index f50e316e2d..687dc0fb49 100644 --- 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 @@ -18,7 +18,7 @@ - + diff --git a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java index 82e88c11ea..1c6d17a398 100644 --- a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java +++ b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java @@ -11,6 +11,8 @@ import org.epics.vtype.Display; import org.epics.vtype.Time; import org.epics.vtype.VDoubleArray; +import org.phoebus.pv.PVFactory; +import org.phoebus.pv.PVPool; import org.phoebus.ui.javafx.ApplicationWrapper; public class ComparisonDialogDemo extends ApplicationWrapper { @@ -21,13 +23,14 @@ public static void main(String[] args){ @Override public void start(Stage primaryStage) throws Exception { + VDoubleArray vDoubleArray = - VDoubleArray.of(ArrayDouble.of(5.0, 6.0, 7.0, 8.0, 9.0), + VDoubleArray.of(ArrayDouble.of(1.0, 6.0, 7.0, 8.0, 9.0), Alarm.none(), Time.now(), Display.none()); ComparisonDialog comparisonDialog = - new ComparisonDialog(vDoubleArray, "MUSIGNY"); + new ComparisonDialog(vDoubleArray, "loc://x(1,2,3,4,15)"); comparisonDialog.show(); } } From c9bacba6250cf2aee0b59dbf61b29fc769c75fc1 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Tue, 2 Dec 2025 15:13:07 +0100 Subject: [PATCH 22/83] Render delta value in comparison dialog --- .../applications/saveandrestore/Messages.java | 2 + .../ui/snapshot/compare/ColumnEntry.java | 39 +++++++++- .../TableComparisonViewController.java | 75 ++++++++++++------- .../saveandrestore/messages.properties | 2 + .../snapshot/compare/TableComparisonView.fxml | 6 +- .../compare/ComparisonDialogDemo.java | 10 ++- .../saveandrestore/util/Utilities.java | 4 +- 7 files changed, 102 insertions(+), 36 deletions(-) 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..45ce27edbc 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 @@ -107,6 +107,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 +160,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/snapshot/compare/ColumnEntry.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ColumnEntry.java index 098a169aa6..1b0ddcdddd 100644 --- 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 @@ -8,11 +8,12 @@ import javafx.beans.property.SimpleObjectProperty; import org.epics.vtype.VType; import org.phoebus.core.vtypes.VDisconnectedData; +import org.phoebus.saveandrestore.util.Utilities; public class ColumnEntry { private final ObjectProperty snapshotVal = new SimpleObjectProperty<>(this, "snapshotValue", null); - private final ObjectProperty delta = new SimpleObjectProperty<>(this, "delta", null); + private final ObjectProperty delta = new SimpleObjectProperty<>(this, "delta", null); private final ObjectProperty liveVal = new SimpleObjectProperty<>(this, "liveValue", (T) VDisconnectedData.INSTANCE); public ColumnEntry(T snapshotVal){ @@ -25,9 +26,45 @@ public ObjectProperty getSnapshotValue(){ public void setLiveVal(T value){ liveVal.set(value); + if(value instanceof String){ + String stringValue = (String)value; + delta.set(new ColumnDelta(stringValue, stringValue.equals(snapshotVal.get()))); + } + else{ + Double valueNumber = ((Number) value).doubleValue(); + Double diff = ((Number) snapshotVal.get()).doubleValue() - valueNumber; + String diffString = Double.toString(diff); + if(diff > 0){ + diffString = "+" + diff; + } + delta.set(new ColumnDelta(diffString, diff != 0)); + } } public ObjectProperty getLiveValue(){ return liveVal; } + + public ObjectProperty getDelta(){ + return delta; + } + + public static class ColumnDelta{ + private String deltaString; + private boolean equal; + + public ColumnDelta(String deltaString, boolean equal){ + this.deltaString = deltaString; + this.equal = equal; + } + + public boolean isEqual() { + return equal; + } + + @Override + public String toString(){ + return deltaString; + } + } } 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 index c9be0cf2d3..e23d250bd7 100644 --- 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 @@ -27,6 +27,7 @@ import org.phoebus.core.vtypes.VDisconnectedData; import org.phoebus.pv.PV; import org.phoebus.pv.PVPool; +import org.phoebus.saveandrestore.util.Utilities; import java.util.ArrayList; import java.util.List; @@ -42,12 +43,17 @@ public class TableComparisonViewController { @SuppressWarnings("unused") @FXML - private TableColumn valueColumn; + private TableColumn storedValueColumn; @SuppressWarnings("unused") @FXML private TableColumn liveValueColumn; + + @SuppressWarnings("unused") + @FXML + private TableColumn deltaColumn; + @SuppressWarnings("unused") @FXML private Label pvName; @@ -63,20 +69,21 @@ public class TableComparisonViewController { public void initialize() { comparisonTable.getStylesheets().add(TableComparisonViewController.class.getResource("/save-and-restore-style.css").toExternalForm()); pvName.textProperty().bind(pvNameProperty); - valueColumn.setCellValueFactory(cell -> + storedValueColumn.setCellValueFactory(cell -> cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getSnapshotValue()); liveValueColumn.setCellValueFactory(cell -> cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getLiveValue()); + deltaColumn.setCellValueFactory(cell -> + cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getDelta()); } public void loadDataAndConnect(VType data, String pvName) { pvNameProperty.set(pvName); - + List columnEntries = new ArrayList<>(); if (data instanceof VNumberArray) { - IteratorNumber iteratorNumber = ((VNumberArray) data).getData().iterator(); - List columnEntries = new ArrayList<>(); int index = 0; + IteratorNumber iteratorNumber = ((VNumberArray) data).getData().iterator(); if (data instanceof VDoubleArray) { while (iteratorNumber.hasNext()) { double value = iteratorNumber.nextDouble(); @@ -119,19 +126,20 @@ public void loadDataAndConnect(VType data, String pvName) { addRow(index, columnEntries, columnEntry); index++; } - } else if (data instanceof VBooleanArray) { - ListBoolean listBoolean = ((VBooleanArray) data).getData(); - for (int i = 0; i < listBoolean.size(); i++) { - boolean value = listBoolean.getBoolean(i); - ColumnEntry columnEntry = new ColumnEntry<>(value); - addRow(index, columnEntries, columnEntry); - } - } else if (data instanceof VEnumArray) { - List enumValues = ((VEnumArray) data).getData(); - for (int i = 0; i < enumValues.size(); i++) { - ColumnEntry columnEntry = new ColumnEntry<>(enumValues.get(i)); - addRow(index, columnEntries, columnEntry); - } + } + } + else if (data instanceof VBooleanArray) { + ListBoolean listBoolean = ((VBooleanArray) data).getData(); + for (int i = 0; i < listBoolean.size(); i++) { + boolean value = listBoolean.getBoolean(i); + ColumnEntry columnEntry = new ColumnEntry<>(value); + addRow(i, columnEntries, columnEntry); + } + } else if (data instanceof VEnumArray) { + List enumValues = ((VEnumArray) data).getData(); + for (int i = 0; i < enumValues.size(); i++) { + ColumnEntry columnEntry = new ColumnEntry<>(enumValues.get(i)); + addRow(i, columnEntries, columnEntry); } } @@ -161,31 +169,46 @@ private void updateTable(VType liveData) { } else { comparisonTable.getItems().forEach(i -> { int index = i.indexProperty().get(); + ColumnEntry columnEntry = i.getColumnEntries().get(index); if (liveData instanceof VDoubleArray) { VDoubleArray array = (VDoubleArray) liveData; - i.getColumnEntries().get(index).setLiveVal(array.getData().getDouble(index)); - } else if (liveData instanceof VIntArray) { + double value = array.getData().getDouble(index); + columnEntry.setLiveVal(value); + double absoluteDelta = (Double)columnEntry.getSnapshotValue().get() - value; + String deltaString = (absoluteDelta > 0 ? "+" : "-") + absoluteDelta; + //columnEntry.setDelta(new Utilities.VTypeComparison(deltaString, absoluteDelta > 0 ? 1 : -1, false, absoluteDelta)); + } /*else if (liveData instanceof VIntArray) { VIntArray array = (VIntArray) liveData; - i.getColumnEntries().get(index).setLiveVal(array.getData().getInt(index)); + int value = array.getData().getInt(index); + columnEntry.setLiveVal(value); + columnEntry.setDelta(((Integer)columnEntry.getSnapshotValue().get()) - value); } else if (liveData instanceof VLongArray) { VLongArray array = (VLongArray) liveData; - i.getColumnEntries().get(index).setLiveVal(array.getData().getLong(index)); + long value = array.getData().getLong(index); + columnEntry.setLiveVal(value); + columnEntry.setDelta(((Long)columnEntry.getSnapshotValue().get()) - value); } else if (liveData instanceof VFloatArray) { VFloatArray array = (VFloatArray) liveData; - i.getColumnEntries().get(index).setLiveVal(array.getData().getFloat(index)); + float value = array.getData().getFloat(index); + columnEntry.setLiveVal(value); + columnEntry.setDelta(((Float)columnEntry.getSnapshotValue().get()) - value); } else if (liveData instanceof VShortArray) { VShortArray array = (VShortArray) liveData; - i.getColumnEntries().get(index).setLiveVal(array.getData().getShort(index)); + short value = array.getData().getShort(index); + columnEntry.setLiveVal(value); + columnEntry.setDelta(((Short)columnEntry.getSnapshotValue().get()) - value); } else if (liveData instanceof VBooleanArray) { VBooleanArray array = (VBooleanArray)liveData; - i.getColumnEntries().get(index).setLiveVal(array.getData().getBoolean(index)); + boolean value = array.getData().getBoolean(index); + columnEntry.setLiveVal(value); + //columnEntry.setDelta(((Boolean)columnEntry.getSnapshotValue().get()) - value); } else if (liveData instanceof VEnumArray) { VEnumArray array = (VEnumArray) liveData; i.getColumnEntries().get(index).setLiveVal(array.getData().get(index)); } else if (liveData instanceof VStringArray) { VStringArray array = (VStringArray) liveData; i.getColumnEntries().get(index).setLiveVal(array.getData().get(index)); - } + }*/ }); } } 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..c97215921b 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 @@ -124,6 +124,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 @@ -216,6 +217,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 index 687dc0fb49..8f6efe2cc6 100644 --- 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 @@ -16,9 +16,9 @@ - - - + + + diff --git a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java index 1c6d17a398..33fc4730a6 100644 --- a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java +++ b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java @@ -5,12 +5,15 @@ package org.phoebus.applications.saveandrestore.ui.snapshot.compare; import javafx.stage.Stage; +import org.epics.util.array.ArrayBoolean; import org.epics.util.array.ArrayDouble; import org.epics.util.array.ListDouble; import org.epics.vtype.Alarm; import org.epics.vtype.Display; import org.epics.vtype.Time; +import org.epics.vtype.VBooleanArray; import org.epics.vtype.VDoubleArray; +import org.epics.vtype.VIntArray; import org.phoebus.pv.PVFactory; import org.phoebus.pv.PVPool; import org.phoebus.ui.javafx.ApplicationWrapper; @@ -25,12 +28,11 @@ public static void main(String[] args){ public void start(Stage primaryStage) throws Exception { VDoubleArray vDoubleArray = - VDoubleArray.of(ArrayDouble.of(1.0, 6.0, 7.0, 8.0, 9.0), + VDoubleArray.of(ArrayDouble.of(1, 2,3, 4, 5), Alarm.none(), - Time.now(), - Display.none()); + Time.now(), Display.none()); ComparisonDialog comparisonDialog = - new ComparisonDialog(vDoubleArray, "loc://x(1,2,3,4,15)"); + new ComparisonDialog(vDoubleArray, "loc://x(1, 8, 7, 0, -1)"); comparisonDialog.show(); } } diff --git a/app/save-and-restore/util/src/main/java/org/phoebus/saveandrestore/util/Utilities.java b/app/save-and-restore/util/src/main/java/org/phoebus/saveandrestore/util/Utilities.java index 9d6283cc69..8329b7c09a 100644 --- a/app/save-and-restore/util/src/main/java/org/phoebus/saveandrestore/util/Utilities.java +++ b/app/save-and-restore/util/src/main/java/org/phoebus/saveandrestore/util/Utilities.java @@ -97,13 +97,13 @@ public static class VTypeComparison { private final boolean withinThreshold; private double absoluteDelta = 0.0; - VTypeComparison(String string, int equal, boolean withinThreshold) { + public VTypeComparison(String string, int equal, boolean withinThreshold) { this.string = string; this.valuesEqual = equal; this.withinThreshold = withinThreshold; } - VTypeComparison(String string, int equal, boolean withinThreshold, double absoluteDelta) { + public VTypeComparison(String string, int equal, boolean withinThreshold, double absoluteDelta) { this.string = string; this.valuesEqual = equal; this.withinThreshold = withinThreshold; From 4838f2b004f3b444ac732811df321e453f7090b0 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Tue, 2 Dec 2025 15:25:10 +0100 Subject: [PATCH 23/83] Add styling of diff column --- .../ui/snapshot/compare/ColumnEntry.java | 2 +- .../ui/snapshot/compare/ComparisonDialog.java | 1 + .../compare/TableComparisonViewController.java | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) 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 index 1b0ddcdddd..779c58823e 100644 --- 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 @@ -37,7 +37,7 @@ public void setLiveVal(T value){ if(diff > 0){ diffString = "+" + diff; } - delta.set(new ColumnDelta(diffString, diff != 0)); + delta.set(new ColumnDelta(diffString, diff == 0)); } } 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 index 53702b4385..c76336faad 100644 --- 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 @@ -20,6 +20,7 @@ public class ComparisonDialog extends Dialog { public ComparisonDialog(VType data, String pvName){ getDialogPane().getButtonTypes().addAll(ButtonType.OK); + setResizable(true); ResourceBundle resourceBundle = NLS.getMessages(Messages.class); FXMLLoader loader = new FXMLLoader(); 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 index e23d250bd7..1f3c951065 100644 --- 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 @@ -9,6 +9,7 @@ import javafx.beans.property.StringProperty; import javafx.fxml.FXML; import javafx.scene.control.Label; +import javafx.scene.control.TableCell; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import org.epics.util.array.IteratorNumber; @@ -24,6 +25,7 @@ import org.epics.vtype.VShortArray; import org.epics.vtype.VStringArray; import org.epics.vtype.VType; +import org.phoebus.applications.saveandrestore.ui.snapshot.TableCellColors; import org.phoebus.core.vtypes.VDisconnectedData; import org.phoebus.pv.PV; import org.phoebus.pv.PVPool; @@ -75,6 +77,22 @@ public void initialize() { cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getLiveValue()); deltaColumn.setCellValueFactory(cell -> cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getDelta()); + + deltaColumn.setCellFactory(e -> new TableCell<>(){ + @Override + public void updateItem(ColumnEntry.ColumnDelta item, boolean empty) { + if(item != null && !empty){ + if(!item.isEqual()){ + setStyle(TableCellColors.ALARM_MAJOR_STYLE); + } + else{ + setStyle(TableCellColors.REGULAR_CELL_STYLE); + } + setText(item.toString()); + } + + } + }); } public void loadDataAndConnect(VType data, String pvName) { From 78385ca29e03f842dacecf9fd71b5a801aea686f Mon Sep 17 00:00:00 2001 From: georgweiss Date: Tue, 2 Dec 2025 15:29:10 +0100 Subject: [PATCH 24/83] Adding comparison dialog title --- .../java/org/phoebus/applications/saveandrestore/Messages.java | 1 + .../saveandrestore/ui/snapshot/compare/ComparisonDialog.java | 3 ++- .../phoebus/applications/saveandrestore/messages.properties | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) 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 45ce27edbc..24c11f962e 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 @@ -39,6 +39,7 @@ public class Messages { public static String closeConfigurationTabPrompt; public static String closeSnapshotTabPrompt; public static String compositeSnapshotConsistencyCheckFailed; + public static String comparisonDialogTitle; public static String contextMenuAddTag; @Deprecated public static String contextMenuAddTagWithComment; 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 index c76336faad..3af5d045bc 100644 --- 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 @@ -19,8 +19,9 @@ public class ComparisonDialog extends Dialog { public ComparisonDialog(VType data, String pvName){ - getDialogPane().getButtonTypes().addAll(ButtonType.OK); + getDialogPane().getButtonTypes().addAll(ButtonType.CLOSE); setResizable(true); + setTitle(Messages.comparisonDialogTitle); ResourceBundle resourceBundle = NLS.getMessages(Messages.class); FXMLLoader loader = new FXMLLoader(); 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 c97215921b..1cb6023b65 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 @@ -29,6 +29,7 @@ copy=Copy createdDate=Created createLogEntry=Create Log Entry createLogEntryToolTip=Create a log entry when snapshot has been saved or restored +comparisonDialogTitle=Comparing array/table data compositeSnapshotName=Composite Snapshot Name configurationLocation=Location configurationName=Configuration Name From 54083d7241344dc195216b649033a9bdd1b51178 Mon Sep 17 00:00:00 2001 From: lcaouen Date: Tue, 2 Dec 2025 15:53:59 +0100 Subject: [PATCH 25/83] load properties from manifest + escape html tags + monospace font --- .../preferences/PropertyPreferenceWriter.java | 124 ++++++++++++------ .../java/org/phoebus/ui/help/OpenAbout.java | 4 +- 2 files changed, 84 insertions(+), 44 deletions(-) diff --git a/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java b/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java index 7ac59d71a0..310df03199 100644 --- a/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java +++ b/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java @@ -16,13 +16,15 @@ import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Enumeration; import java.util.HashMap; import java.util.Map; import java.util.Properties; -import java.util.StringTokenizer; import java.util.jar.JarEntry; import java.util.jar.JarFile; +import java.util.jar.Manifest; import java.util.prefs.Preferences; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -48,13 +50,13 @@ public static void save(final OutputStream stream) throws Exception { try ( - final OutputStreamWriter out = new OutputStreamWriter(stream); ) { out.append("# Preference settings
\n"); out.append("# Format:
\n"); out.append("# the.package.name/key=value
\n"); + out.append("

# key=value in red are incorrect properties

\n"); listSettings(getAllPropertyKeys(), out, Preferences.userRoot()); out.append("# End.
\n"); out.flush(); @@ -76,9 +78,9 @@ private static void formatSetting(Map allKeysWithPackages, final String keyFound = allKeysWithPackages.get(fullKey); boolean bNotFound = keyFound == null ? true : false; if (bNotFound) out.append("
"); - out.append(fullKey) + out.append(escapeHtml(fullKey)) .append('=') - .append(node.get(key, "")) + .append(escapeHtml(node.get(key, ""))) .append("
\n"); if (bNotFound) out.append("
"); } @@ -88,13 +90,14 @@ private static Map getAllPropertyKeys() throws Exception Map allKeysWithPackages = new HashMap<>(); String classpath = System.getProperty("java.class.path"); - StringTokenizer tokenizer = new StringTokenizer(classpath, System.getProperty("path.separator")); + String[] jars = classpath.split(System.getProperty("path.separator")); - while (tokenizer.hasMoreTokens()) { - String path = tokenizer.nextToken(); - File file = new File(path); + if (jars.length == 1) jars = getAllJarFromManifest(jars[0]); - if (path.endsWith(".jar")) { + for (String jarEntry : jars) { + File file = new File(jarEntry); + + if (jarEntry.endsWith(".jar")) { try (JarFile jarFile = new JarFile(file)) { Enumeration entries = jarFile.entries(); @@ -111,7 +114,7 @@ private static Map getAllPropertyKeys() throws Exception } } } catch (IOException e) { - System.err.println("Error opening JAR : " + path); + System.err.println("Error opening JAR : " + jarEntry); e.printStackTrace(); } } @@ -120,44 +123,79 @@ private static Map getAllPropertyKeys() throws Exception return allKeysWithPackages; } - private static void parsePropertiesWithPackage( - InputStream inputStream, - String fileName, - Map allKeysWithPackages - ) { - Properties props = new Properties(); - String packageName = null; - - try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { - String line; - StringBuilder content = new StringBuilder(); - - while ((line = reader.readLine()) != null) { - line = line.trim(); - if (line.startsWith("#") && line.contains("Package")) { - // Find package name - Pattern pattern = Pattern.compile("#\\s*Package\\s+([^\\s]+)"); - Matcher matcher = pattern.matcher(line); - if (matcher.find()) { - packageName = matcher.group(1); - } - } else if (!line.startsWith("#")) { - content.append(line).append("\n"); + private static String[] getAllJarFromManifest(String jarPath) { + String[] jars = new String[0]; + File jarFile = new File(jarPath); + + try (JarFile jar = new JarFile(jarFile)) { + Manifest manifest = jar.getManifest(); + + if (manifest != null) { + String classPath = manifest.getMainAttributes().getValue("Class-Path"); + + if (classPath != null && !classPath.isEmpty()) { + jars = classPath.split(" "); + + for (int iJar = 0; iJar < jars.length; iJar++) { + Path fullPath = Paths.get(jarFile.getParent()).resolve(jars[iJar]); + jars[iJar] = fullPath.toString(); } + } else { + System.err.println("No Class-Path found in MANIFEST.MF."); } + } else { + System.err.println("MANIFEST.MF not found in the JAR."); + } + } catch (IOException e) { + System.err.println("Error when reading the jar : " + jarPath); + e.printStackTrace(); + } + + return jars; + } + + private static void parsePropertiesWithPackage(InputStream inputStream, String fileName, Map allKeysWithPackages) { + Properties props = new Properties(); + String packageName = null; - if (content.length() > 0) { - props.load(new ByteArrayInputStream(content.toString().getBytes())); - } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + StringBuilder content = new StringBuilder(); - // properties found - for (String key : props.stringPropertyNames()) { - String prefixedKey = (packageName != null) ? packageName + "/" + key : key; - allKeysWithPackages.put(prefixedKey, props.getProperty(key)); + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.startsWith("#") && line.contains("Package")) { + // Find package name + Pattern pattern = Pattern.compile("#\\s*Package\\s+([^\\s]+)"); + Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + packageName = matcher.group(1); + } + } else if (!line.startsWith("#")) { + content.append(line).append("\n"); } - } catch (IOException e) { - System.err.println("Error when reading file " + fileName); - e.printStackTrace(); } + + if (content.length() > 0) { + props.load(new ByteArrayInputStream(content.toString().getBytes())); + } + + // properties found + for (String key : props.stringPropertyNames()) { + String prefixedKey = (packageName != null) ? packageName + "/" + key : key; + allKeysWithPackages.put(prefixedKey, props.getProperty(key)); + } + } catch (IOException e) { + System.err.println("Error when reading file " + fileName); + e.printStackTrace(); } + } + + private static String escapeHtml(String input) { + return input.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } } diff --git a/core/ui/src/main/java/org/phoebus/ui/help/OpenAbout.java b/core/ui/src/main/java/org/phoebus/ui/help/OpenAbout.java index f9c90c5726..254d9d48d5 100644 --- a/core/ui/src/main/java/org/phoebus/ui/help/OpenAbout.java +++ b/core/ui/src/main/java/org/phoebus/ui/help/OpenAbout.java @@ -206,7 +206,9 @@ private Node createDetailSection() } WebView webView = new WebView(); - String content = ""; + String content = ""; content += prefs_buf.toString(); content += ""; webView.getEngine().loadContent(content); From 85b257dbf0284ef17fc5642cf8931ee4657f367e Mon Sep 17 00:00:00 2001 From: georgweiss Date: Tue, 2 Dec 2025 16:37:31 +0100 Subject: [PATCH 26/83] Additional string resources --- .../java/org/phoebus/applications/saveandrestore/Messages.java | 1 + .../org/phoebus/applications/saveandrestore/messages.properties | 1 + 2 files changed, 2 insertions(+) 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 24c11f962e..8dd7ac7039 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 @@ -39,6 +39,7 @@ 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 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 1cb6023b65..6d19e32639 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 @@ -29,6 +29,7 @@ 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 From 453a2a3a46b30c6e6e0aa21093704c4225be8a69 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Tue, 2 Dec 2025 19:56:27 +0100 Subject: [PATCH 27/83] Allow indexed access of scalar array elements for pva --- .../java/org/phoebus/pv/pva/PVAStructureHelper.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/core/pv-pva/src/main/java/org/phoebus/pv/pva/PVAStructureHelper.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/PVAStructureHelper.java index 3d5f8d4d74..e6282c15d9 100644 --- a/core/pv-pva/src/main/java/org/phoebus/pv/pva/PVAStructureHelper.java +++ b/core/pv-pva/src/main/java/org/phoebus/pv/pva/PVAStructureHelper.java @@ -110,8 +110,14 @@ else if (field instanceof PVAString) return decodeScalar(actual); if (type.equals("NTEnum:1.0")) return Decoders.decodeEnum(actual); - if (type.equals("NTScalarArray:1.0")) - return decodeNTArray(actual); + if (type.equals("NTScalarArray:1.0")) { + if(elementIndex.isPresent()){ + return decodeNTArray(actual, elementIndex.get()); + } + else{ + return decodeNTArray(actual); + } + } if (type.equals("NTNDArray:1.0")) return ImageDecoder.decode(actual); if (type.equals("NTTable:1.0")) From e3a5f0954cf3742ae0a9c9ca56148563f8a108d9 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Wed, 3 Dec 2025 11:09:53 +0100 Subject: [PATCH 28/83] More styling and fixed sorting on delta column --- .../ui/snapshot/compare/ColumnEntry.java | 55 +++++++++++-------- .../TableComparisonViewController.java | 50 +++++++---------- .../snapshot/compare/TableComparisonView.fxml | 6 +- .../compare/ComparisonDialogDemo.java | 16 +++--- 4 files changed, 63 insertions(+), 64 deletions(-) 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 index 779c58823e..e83c00911b 100644 --- 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 @@ -6,10 +6,12 @@ import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; -import org.epics.vtype.VType; import org.phoebus.core.vtypes.VDisconnectedData; -import org.phoebus.saveandrestore.util.Utilities; +/** + * Data class for one column in the comparison table. + * @param + */ public class ColumnEntry { private final ObjectProperty snapshotVal = new SimpleObjectProperty<>(this, "snapshotValue", null); @@ -26,19 +28,7 @@ public ObjectProperty getSnapshotValue(){ public void setLiveVal(T value){ liveVal.set(value); - if(value instanceof String){ - String stringValue = (String)value; - delta.set(new ColumnDelta(stringValue, stringValue.equals(snapshotVal.get()))); - } - else{ - Double valueNumber = ((Number) value).doubleValue(); - Double diff = ((Number) snapshotVal.get()).doubleValue() - valueNumber; - String diffString = Double.toString(diff); - if(diff > 0){ - diffString = "+" + diff; - } - delta.set(new ColumnDelta(diffString, diff == 0)); - } + delta.set(new ColumnDelta()); } public ObjectProperty getLiveValue(){ @@ -49,13 +39,34 @@ public ObjectProperty getDelta(){ return delta; } - public static class ColumnDelta{ - private String deltaString; - private boolean equal; + /** + * Class wrapping data needed to render or sort the delta column. + */ + public class ColumnDelta{ + private final boolean equal; + private String displayString; + private final double absoluteDelta; + + public ColumnDelta(){ + if(liveVal.get() instanceof String){ + String stringValue = (String)liveVal.get(); + displayString = (String)snapshotVal.get(); + absoluteDelta = Math.abs((displayString).compareTo(stringValue)); + } + else{ + double valueNumber = ((Number) liveVal.get()).doubleValue(); + double diff = ((Number) snapshotVal.get()).doubleValue() - valueNumber; + displayString = Double.toString(diff); + if(diff > 0){ + displayString = "+" + diff; + } + absoluteDelta = Math.abs(diff); + } + equal = absoluteDelta == 0; + } - public ColumnDelta(String deltaString, boolean equal){ - this.deltaString = deltaString; - this.equal = equal; + public double getAbsoluteDelta(){ + return absoluteDelta; } public boolean isEqual() { @@ -64,7 +75,7 @@ public boolean isEqual() { @Override public String toString(){ - return deltaString; + return displayString; } } } 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 index 1f3c951065..fff212b13c 100644 --- 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 @@ -29,9 +29,9 @@ import org.phoebus.core.vtypes.VDisconnectedData; import org.phoebus.pv.PV; import org.phoebus.pv.PVPool; -import org.phoebus.saveandrestore.util.Utilities; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.logging.Level; @@ -43,6 +43,10 @@ public class TableComparisonViewController { @FXML private TableView comparisonTable; + @SuppressWarnings("unused") + @FXML + private TableColumn indexColumn; + @SuppressWarnings("unused") @FXML private TableColumn storedValueColumn; @@ -77,20 +81,19 @@ public void initialize() { cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getLiveValue()); deltaColumn.setCellValueFactory(cell -> cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getDelta()); + deltaColumn.setComparator(Comparator.comparingDouble(ColumnEntry.ColumnDelta::getAbsoluteDelta)); - deltaColumn.setCellFactory(e -> new TableCell<>(){ + deltaColumn.setCellFactory(e -> new TableCell<>() { @Override public void updateItem(ColumnEntry.ColumnDelta item, boolean empty) { - if(item != null && !empty){ - if(!item.isEqual()){ + if (item != null && !empty) { + if (!item.isEqual()) { setStyle(TableCellColors.ALARM_MAJOR_STYLE); - } - else{ + } else { setStyle(TableCellColors.REGULAR_CELL_STYLE); } setText(item.toString()); } - } }); } @@ -145,8 +148,7 @@ public void loadDataAndConnect(VType data, String pvName) { index++; } } - } - else if (data instanceof VBooleanArray) { + } else if (data instanceof VBooleanArray) { ListBoolean listBoolean = ((VBooleanArray) data).getData(); for (int i = 0; i < listBoolean.size(); i++) { boolean value = listBoolean.getBoolean(i); @@ -188,45 +190,33 @@ private void updateTable(VType liveData) { comparisonTable.getItems().forEach(i -> { int index = i.indexProperty().get(); ColumnEntry columnEntry = i.getColumnEntries().get(index); - if (liveData instanceof VDoubleArray) { - VDoubleArray array = (VDoubleArray) liveData; - double value = array.getData().getDouble(index); - columnEntry.setLiveVal(value); - double absoluteDelta = (Double)columnEntry.getSnapshotValue().get() - value; - String deltaString = (absoluteDelta > 0 ? "+" : "-") + absoluteDelta; - //columnEntry.setDelta(new Utilities.VTypeComparison(deltaString, absoluteDelta > 0 ? 1 : -1, false, absoluteDelta)); - } /*else if (liveData instanceof VIntArray) { + if (liveData instanceof VDoubleArray array) { + columnEntry.setLiveVal(array.getData().getDouble(index)); + } else if (liveData instanceof VIntArray) { VIntArray array = (VIntArray) liveData; - int value = array.getData().getInt(index); - columnEntry.setLiveVal(value); - columnEntry.setDelta(((Integer)columnEntry.getSnapshotValue().get()) - value); + columnEntry.setLiveVal(array.getData().getDouble(index)); } else if (liveData instanceof VLongArray) { VLongArray array = (VLongArray) liveData; long value = array.getData().getLong(index); - columnEntry.setLiveVal(value); - columnEntry.setDelta(((Long)columnEntry.getSnapshotValue().get()) - value); + columnEntry.setLiveVal(array.getData().getDouble(index)); } else if (liveData instanceof VFloatArray) { VFloatArray array = (VFloatArray) liveData; float value = array.getData().getFloat(index); - columnEntry.setLiveVal(value); - columnEntry.setDelta(((Float)columnEntry.getSnapshotValue().get()) - value); + columnEntry.setLiveVal(array.getData().getDouble(index)); } else if (liveData instanceof VShortArray) { VShortArray array = (VShortArray) liveData; short value = array.getData().getShort(index); - columnEntry.setLiveVal(value); - columnEntry.setDelta(((Short)columnEntry.getSnapshotValue().get()) - value); + columnEntry.setLiveVal(array.getData().getDouble(index)); } else if (liveData instanceof VBooleanArray) { VBooleanArray array = (VBooleanArray)liveData; - boolean value = array.getData().getBoolean(index); - columnEntry.setLiveVal(value); - //columnEntry.setDelta(((Boolean)columnEntry.getSnapshotValue().get()) - value); + columnEntry.setLiveVal(array.getData().getBoolean(index)); } else if (liveData instanceof VEnumArray) { VEnumArray array = (VEnumArray) liveData; i.getColumnEntries().get(index).setLiveVal(array.getData().get(index)); } else if (liveData instanceof VStringArray) { VStringArray array = (VStringArray) liveData; i.getColumnEntries().get(index).setLiveVal(array.getData().get(index)); - }*/ + } }); } } 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 index 8f6efe2cc6..bc723e2414 100644 --- 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 @@ -16,9 +16,9 @@ - - - + + + diff --git a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java index 33fc4730a6..8fdf54e609 100644 --- a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java +++ b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java @@ -5,30 +5,28 @@ package org.phoebus.applications.saveandrestore.ui.snapshot.compare; import javafx.stage.Stage; -import org.epics.util.array.ArrayBoolean; import org.epics.util.array.ArrayDouble; -import org.epics.util.array.ListDouble; import org.epics.vtype.Alarm; import org.epics.vtype.Display; import org.epics.vtype.Time; -import org.epics.vtype.VBooleanArray; import org.epics.vtype.VDoubleArray; -import org.epics.vtype.VIntArray; -import org.phoebus.pv.PVFactory; -import org.phoebus.pv.PVPool; import org.phoebus.ui.javafx.ApplicationWrapper; +/** + * Utility class for the purpose of testing the {@link ComparisonDialog}. It uses + * a local array data source for comparison to a hard coded {@link VDoubleArray}. + */ public class ComparisonDialogDemo extends ApplicationWrapper { - public static void main(String[] args){ + public static void main(String[] args) { launch(ComparisonDialogDemo.class, args); } @Override - public void start(Stage primaryStage) throws Exception { + public void start(Stage primaryStage) { VDoubleArray vDoubleArray = - VDoubleArray.of(ArrayDouble.of(1, 2,3, 4, 5), + VDoubleArray.of(ArrayDouble.of(1, 2, 3, 4, 5), Alarm.none(), Time.now(), Display.none()); ComparisonDialog comparisonDialog = From 6c6eef481b53d12676d2e00833362501044ac3c8 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Wed, 3 Dec 2025 13:55:30 +0100 Subject: [PATCH 29/83] Clickable delta cell to launch dialog if arrays are not equal --- .../applications/saveandrestore/Messages.java | 1 + .../ui/snapshot/VDeltaCellEditor.java | 37 +++++++++++++++---- .../TableComparisonViewController.java | 3 -- .../saveandrestore/messages.properties | 1 + 4 files changed, 31 insertions(+), 11 deletions(-) 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 8dd7ac7039..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; 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..5aeb4d7f13 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,17 @@ 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.epics.vtype.VTable; +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; @@ -74,15 +81,29 @@ public void updateItem(T item, boolean empty) { setText(pair.value.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 = 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 { + 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/compare/TableComparisonViewController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonViewController.java index fff212b13c..d201ae3b15 100644 --- 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 @@ -197,15 +197,12 @@ private void updateTable(VType liveData) { columnEntry.setLiveVal(array.getData().getDouble(index)); } else if (liveData instanceof VLongArray) { VLongArray array = (VLongArray) liveData; - long value = array.getData().getLong(index); columnEntry.setLiveVal(array.getData().getDouble(index)); } else if (liveData instanceof VFloatArray) { VFloatArray array = (VFloatArray) liveData; - float value = array.getData().getFloat(index); columnEntry.setLiveVal(array.getData().getDouble(index)); } else if (liveData instanceof VShortArray) { VShortArray array = (VShortArray) liveData; - short value = array.getData().getShort(index); columnEntry.setLiveVal(array.getData().getDouble(index)); } else if (liveData instanceof VBooleanArray) { VBooleanArray array = (VBooleanArray)liveData; 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 6d19e32639..7070d4f03f 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? From 70ced28f3491c9dcef0fba7ea1bcc2cdbdde6f60 Mon Sep 17 00:00:00 2001 From: Alexis Gaget Date: Wed, 26 Nov 2025 17:05:39 +0100 Subject: [PATCH 30/83] Change empy`Name` properties by the name of the file in runtime and in editor --- .../builder/editor/app/DisplayEditorInstance.java | 12 +++++++++--- .../csstudio/display/builder/model/DisplayModel.java | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) 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/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; } From 4e0c31c9884d969a66f62310b79fa0b0d03af564 Mon Sep 17 00:00:00 2001 From: Alexis Gaget Date: Wed, 3 Dec 2025 17:27:54 +0100 Subject: [PATCH 31/83] change default value of the property of the template initial.bob --- app/display/model/src/main/resources/examples/initial.bob | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 + Label TITLE From 7141bd3222d3c1299353ddc37ba0bba7de56916b Mon Sep 17 00:00:00 2001 From: georgweiss Date: Thu, 4 Dec 2025 13:26:26 +0100 Subject: [PATCH 32/83] Using VTypes instead of priminitives for the comparison dialog --- .../saveandrestore/ui/VTypePair.java | 27 ++++++ .../ui/snapshot/SnapshotController.java | 4 +- .../ui/snapshot/VDeltaCellEditor.java | 11 ++- .../ui/snapshot/VTypeCellEditor.java | 4 +- .../ui/snapshot/compare/ColumnEntry.java | 87 +++++++------------ .../TableComparisonViewController.java | 80 +++++++++-------- 6 files changed, 110 insertions(+), 103 deletions(-) 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..c792272786 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,6 +17,8 @@ */ package org.phoebus.applications.saveandrestore.ui; +import org.epics.vtype.VNumber; +import org.epics.vtype.VString; import org.epics.vtype.VType; import org.phoebus.saveandrestore.util.Threshold; @@ -47,6 +49,31 @@ 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 + * absolute value to. + * + *

+ * Main use case for this is ordering on delta. Absolute delta is more useful as otherwise zero + * deltas would be found between positive and negative deltas. + *

+ * @return + */ + public double getAbsoluteDelta(){ + if(base == null || value == null){ + return 0.0; + } + 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 5aeb4d7f13..07e5840430 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 @@ -23,7 +23,6 @@ import org.epics.vtype.VEnumArray; import org.epics.vtype.VNumberArray; import org.epics.vtype.VStringArray; -import org.epics.vtype.VTable; import org.phoebus.applications.saveandrestore.Messages; import org.phoebus.applications.saveandrestore.ui.VTypePair; import org.phoebus.applications.saveandrestore.ui.snapshot.compare.ComparisonDialog; @@ -41,7 +40,7 @@ * @param * @author Kunal Shroff */ -public class VDeltaCellEditor extends VTypeCellEditor { +public class VDeltaCellEditor extends VTypeCellEditor { private final Tooltip tooltip = new Tooltip(); @@ -51,7 +50,7 @@ protected void setShowDeltaPercentage(boolean showDeltaPercentage) { this.showDeltaPercentage = showDeltaPercentage; } - VDeltaCellEditor() { + public VDeltaCellEditor() { super(); } @@ -83,9 +82,9 @@ public void updateItem(T item, boolean empty) { Utilities.VTypeComparison vtc = Utilities.deltaValueToString(pair.value, pair.base, pair.threshold); if (vtc.getValuesEqual() != 0 && (pair.base instanceof VNumberArray || - pair.base instanceof VStringArray || - pair.base instanceof VEnumArray)) { - TableEntry tableEntry = getTableRow().getItem(); + pair.base instanceof VStringArray || + pair.base instanceof VEnumArray)) { + TableEntry tableEntry = (TableEntry) getTableRow().getItem(); setText(Messages.clickToCompare); setStyle(TableCellColors.ALARM_MAJOR_STYLE); setOnMouseClicked(e -> { 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 index e83c00911b..2745aeb44e 100644 --- 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 @@ -6,76 +6,53 @@ import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; +import org.epics.vtype.VType; +import org.phoebus.applications.saveandrestore.ui.VTypePair; import org.phoebus.core.vtypes.VDisconnectedData; +import org.phoebus.saveandrestore.util.Threshold; +import org.phoebus.saveandrestore.util.VNoData; + +import java.util.Optional; /** * Data class for one column in the comparison table. - * @param */ -public class ColumnEntry { +public class ColumnEntry { - private final ObjectProperty snapshotVal = new SimpleObjectProperty<>(this, "snapshotValue", null); - private final ObjectProperty delta = new SimpleObjectProperty<>(this, "delta", null); - private final ObjectProperty liveVal = new SimpleObjectProperty<>(this, "liveValue", (T) VDisconnectedData.INSTANCE); + /** + * 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 libe {@link VType} value as read from a connected PV. + */ + private final ObjectProperty liveValue = new SimpleObjectProperty<>(this, "liveValue", VNoData.INSTANCE); - public ColumnEntry(T snapshotVal){ - this.snapshotVal.set(snapshotVal); - } + private Optional> threshold = Optional.empty(); - public ObjectProperty getSnapshotValue(){ - return snapshotVal; + public ColumnEntry(VType storedValue) { + this.storedValue.set(storedValue); } - public void setLiveVal(T value){ - liveVal.set(value); - delta.set(new ColumnDelta()); + public ObjectProperty storedValueProperty() { + return storedValue; } - public ObjectProperty getLiveValue(){ - return liveVal; + public void setLiveVal(VType value) { + liveValue.set(value); + VTypePair vTypePair = new VTypePair(storedValue.get(), value, threshold); + delta.set(vTypePair); } - public ObjectProperty getDelta(){ - return delta; + public ObjectProperty liveValueProperty() { + return liveValue; } - /** - * Class wrapping data needed to render or sort the delta column. - */ - public class ColumnDelta{ - private final boolean equal; - private String displayString; - private final double absoluteDelta; - - public ColumnDelta(){ - if(liveVal.get() instanceof String){ - String stringValue = (String)liveVal.get(); - displayString = (String)snapshotVal.get(); - absoluteDelta = Math.abs((displayString).compareTo(stringValue)); - } - else{ - double valueNumber = ((Number) liveVal.get()).doubleValue(); - double diff = ((Number) snapshotVal.get()).doubleValue() - valueNumber; - displayString = Double.toString(diff); - if(diff > 0){ - displayString = "+" + diff; - } - absoluteDelta = Math.abs(diff); - } - equal = absoluteDelta == 0; - } - - public double getAbsoluteDelta(){ - return absoluteDelta; - } - - public boolean isEqual() { - return equal; - } - - @Override - public String toString(){ - return displayString; - } + public ObjectProperty getDelta() { + return delta; } } 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 index d201ae3b15..2655df48f1 100644 --- 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 @@ -5,27 +5,41 @@ package org.phoebus.applications.saveandrestore.ui.snapshot.compare; +import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.fxml.FXML; import javafx.scene.control.Label; -import javafx.scene.control.TableCell; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import org.epics.util.array.IteratorNumber; import org.epics.util.array.ListBoolean; +import org.epics.vtype.Alarm; +import org.epics.vtype.Display; +import org.epics.vtype.Time; +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.VEnum; 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.phoebus.applications.saveandrestore.ui.snapshot.TableCellColors; +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.pv.PV; import org.phoebus.pv.PVPool; @@ -49,16 +63,16 @@ public class TableComparisonViewController { @SuppressWarnings("unused") @FXML - private TableColumn storedValueColumn; + private TableColumn storedValueColumn; @SuppressWarnings("unused") @FXML - private TableColumn liveValueColumn; + private TableColumn liveValueColumn; @SuppressWarnings("unused") @FXML - private TableColumn deltaColumn; + private TableColumn deltaColumn; @SuppressWarnings("unused") @FXML @@ -76,26 +90,16 @@ 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(cell.getValue().indexProperty().get()).getSnapshotValue()); + cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).storedValueProperty()); + storedValueColumn.setCellFactory(e -> new VTypeCellEditor<>()); liveValueColumn.setCellValueFactory(cell -> - cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getLiveValue()); + cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).liveValueProperty()); + liveValueColumn.setCellFactory(e -> new VTypeCellEditor<>()); deltaColumn.setCellValueFactory(cell -> cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getDelta()); - deltaColumn.setComparator(Comparator.comparingDouble(ColumnEntry.ColumnDelta::getAbsoluteDelta)); - - deltaColumn.setCellFactory(e -> new TableCell<>() { - @Override - public void updateItem(ColumnEntry.ColumnDelta item, boolean empty) { - if (item != null && !empty) { - if (!item.isEqual()) { - setStyle(TableCellColors.ALARM_MAJOR_STYLE); - } else { - setStyle(TableCellColors.REGULAR_CELL_STYLE); - } - setText(item.toString()); - } - } - }); + deltaColumn.setComparator(Comparator.comparingDouble(VTypePair::getAbsoluteDelta)); + + deltaColumn.setCellFactory(e -> new VDeltaCellEditor<>()); } public void loadDataAndConnect(VType data, String pvName) { @@ -108,42 +112,42 @@ public void loadDataAndConnect(VType data, String pvName) { if (data instanceof VDoubleArray) { while (iteratorNumber.hasNext()) { double value = iteratorNumber.nextDouble(); - ColumnEntry columnEntry = new ColumnEntry<>(value); + ColumnEntry columnEntry = new ColumnEntry(VDouble.of(value, Alarm.none(), Time.now(), Display.none())); addRow(index, columnEntries, columnEntry); index++; } } else if (data instanceof VFloatArray) { while (iteratorNumber.hasNext()) { float value = iteratorNumber.nextFloat(); - ColumnEntry columnEntry = new ColumnEntry<>(value); + ColumnEntry columnEntry = new ColumnEntry(VFloat.of(value, Alarm.none(), Time.now(), Display.none())); addRow(index, columnEntries, columnEntry); index++; } } else if (data instanceof VIntArray) { while (iteratorNumber.hasNext()) { int value = iteratorNumber.nextInt(); - ColumnEntry columnEntry = new ColumnEntry<>(value); + ColumnEntry columnEntry = new ColumnEntry(VInt.of(value, Alarm.none(), Time.now(), Display.none())); addRow(index, columnEntries, columnEntry); index++; } } else if (data instanceof VLongArray) { while (iteratorNumber.hasNext()) { long value = iteratorNumber.nextLong(); - ColumnEntry columnEntry = new ColumnEntry<>(value); + ColumnEntry columnEntry = new ColumnEntry(VLong.of(value, Alarm.none(), Time.now(), Display.none())); addRow(index, columnEntries, columnEntry); index++; } } else if (data instanceof VShortArray) { while (iteratorNumber.hasNext()) { short value = iteratorNumber.nextShort(); - ColumnEntry columnEntry = new ColumnEntry<>(value); + ColumnEntry columnEntry = new ColumnEntry(VShort.of(value, Alarm.none(), Time.now(), Display.none())); addRow(index, columnEntries, columnEntry); index++; } } else if (data instanceof VByteArray) { while (iteratorNumber.hasNext()) { byte value = iteratorNumber.nextByte(); - ColumnEntry columnEntry = new ColumnEntry<>(value); + ColumnEntry columnEntry = new ColumnEntry(VByte.of(value, Alarm.none(), Time.now(), Display.none())); addRow(index, columnEntries, columnEntry); index++; } @@ -152,13 +156,13 @@ public void loadDataAndConnect(VType data, String pvName) { ListBoolean listBoolean = ((VBooleanArray) data).getData(); for (int i = 0; i < listBoolean.size(); i++) { boolean value = listBoolean.getBoolean(i); - ColumnEntry columnEntry = new ColumnEntry<>(value); + ColumnEntry columnEntry = new ColumnEntry(VBoolean.of(value, Alarm.none(), Time.now())); addRow(i, columnEntries, columnEntry); } } else if (data instanceof VEnumArray) { List enumValues = ((VEnumArray) data).getData(); for (int i = 0; i < enumValues.size(); i++) { - ColumnEntry columnEntry = new ColumnEntry<>(enumValues.get(i)); + ColumnEntry columnEntry = new ColumnEntry(VString.of(enumValues.get(i), Alarm.none(), Time.now())); addRow(i, columnEntries, columnEntry); } } @@ -191,28 +195,28 @@ private void updateTable(VType liveData) { int index = i.indexProperty().get(); ColumnEntry columnEntry = i.getColumnEntries().get(index); if (liveData instanceof VDoubleArray array) { - columnEntry.setLiveVal(array.getData().getDouble(index)); + columnEntry.setLiveVal(VDouble.of(array.getData().getDouble(index), Alarm.none(), Time.now(), Display.none())); } else if (liveData instanceof VIntArray) { VIntArray array = (VIntArray) liveData; - columnEntry.setLiveVal(array.getData().getDouble(index)); + columnEntry.setLiveVal(VInt.of(array.getData().getInt(index), Alarm.none(), Time.now(), Display.none())); } else if (liveData instanceof VLongArray) { VLongArray array = (VLongArray) liveData; - columnEntry.setLiveVal(array.getData().getDouble(index)); + columnEntry.setLiveVal(VLong.of(array.getData().getLong(index), Alarm.none(), Time.now(), Display.none())); } else if (liveData instanceof VFloatArray) { VFloatArray array = (VFloatArray) liveData; - columnEntry.setLiveVal(array.getData().getDouble(index)); + columnEntry.setLiveVal(VFloat.of(array.getData().getFloat(index), Alarm.none(), Time.now(), Display.none())); } else if (liveData instanceof VShortArray) { VShortArray array = (VShortArray) liveData; - columnEntry.setLiveVal(array.getData().getDouble(index)); + columnEntry.setLiveVal(VShort.of(array.getData().getShort(index), Alarm.none(), Time.now(), Display.none())); } else if (liveData instanceof VBooleanArray) { VBooleanArray array = (VBooleanArray)liveData; - columnEntry.setLiveVal(array.getData().getBoolean(index)); + columnEntry.setLiveVal(VBoolean.of(array.getData().getBoolean(index), Alarm.none(), Time.now())); } else if (liveData instanceof VEnumArray) { VEnumArray array = (VEnumArray) liveData; - i.getColumnEntries().get(index).setLiveVal(array.getData().get(index)); + i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), Alarm.none(), Time.now())); } else if (liveData instanceof VStringArray) { VStringArray array = (VStringArray) liveData; - i.getColumnEntries().get(index).setLiveVal(array.getData().get(index)); + i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), Alarm.none(), Time.now())); } }); } From 0f70bee1dbad41fd2d281711c577f985fe302b7e Mon Sep 17 00:00:00 2001 From: georgweiss Date: Thu, 4 Dec 2025 13:50:43 +0100 Subject: [PATCH 33/83] Handle update if live data has fewer elements than stored snapshot --- .../TableComparisonViewController.java | 42 ++++++++++++------- .../compare/ComparisonDialogDemo.java | 2 +- 2 files changed, 29 insertions(+), 15 deletions(-) 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 index 2655df48f1..d0eba16a14 100644 --- 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 @@ -43,6 +43,7 @@ import org.phoebus.core.vtypes.VDisconnectedData; import org.phoebus.pv.PV; import org.phoebus.pv.PVPool; +import org.phoebus.saveandrestore.util.VNoData; import java.util.ArrayList; import java.util.Comparator; @@ -194,28 +195,41 @@ private void updateTable(VType liveData) { comparisonTable.getItems().forEach(i -> { int index = i.indexProperty().get(); ColumnEntry columnEntry = i.getColumnEntries().get(index); - if (liveData instanceof VDoubleArray array) { - columnEntry.setLiveVal(VDouble.of(array.getData().getDouble(index), Alarm.none(), Time.now(), Display.none())); - } else if (liveData instanceof VIntArray) { - VIntArray array = (VIntArray) liveData; - columnEntry.setLiveVal(VInt.of(array.getData().getInt(index), Alarm.none(), Time.now(), Display.none())); - } else if (liveData instanceof VLongArray) { - VLongArray array = (VLongArray) liveData; - columnEntry.setLiveVal(VLong.of(array.getData().getLong(index), Alarm.none(), Time.now(), Display.none())); - } else if (liveData instanceof VFloatArray) { - VFloatArray array = (VFloatArray) liveData; - columnEntry.setLiveVal(VFloat.of(array.getData().getFloat(index), Alarm.none(), Time.now(), Display.none())); - } else if (liveData instanceof VShortArray) { - VShortArray array = (VShortArray) liveData; - columnEntry.setLiveVal(VShort.of(array.getData().getShort(index), Alarm.none(), Time.now(), Display.none())); + if(liveData instanceof VNumberArray){ + if(index >= ((VNumberArray)liveData).getData().size()){ + columnEntry.setLiveVal(VNoData.INSTANCE); + } else if (liveData instanceof VDoubleArray array) { + columnEntry.setLiveVal(VDouble.of(array.getData().getDouble(index), Alarm.none(), Time.now(), Display.none())); + } else if (liveData instanceof VIntArray) { + VIntArray array = (VIntArray) liveData; + columnEntry.setLiveVal(VInt.of(array.getData().getInt(index), Alarm.none(), Time.now(), Display.none())); + } else if (liveData instanceof VLongArray) { + VLongArray array = (VLongArray) liveData; + columnEntry.setLiveVal(VLong.of(array.getData().getLong(index), Alarm.none(), Time.now(), Display.none())); + } else if (liveData instanceof VFloatArray) { + VFloatArray array = (VFloatArray) liveData; + columnEntry.setLiveVal(VFloat.of(array.getData().getFloat(index), Alarm.none(), Time.now(), Display.none())); + } else if (liveData instanceof VShortArray) { + VShortArray array = (VShortArray) liveData; + columnEntry.setLiveVal(VShort.of(array.getData().getShort(index), Alarm.none(), Time.now(), Display.none())); + } } else if (liveData instanceof VBooleanArray) { VBooleanArray array = (VBooleanArray)liveData; + if(index > array.getData().size()){ + columnEntry.setLiveVal(VNoData.INSTANCE); + } columnEntry.setLiveVal(VBoolean.of(array.getData().getBoolean(index), Alarm.none(), Time.now())); } else if (liveData instanceof VEnumArray) { VEnumArray array = (VEnumArray) liveData; + if(index > array.getData().size()){ + columnEntry.setLiveVal(VNoData.INSTANCE); + } i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), Alarm.none(), Time.now())); } else if (liveData instanceof VStringArray) { VStringArray array = (VStringArray) liveData; + if(index > array.getData().size()){ + columnEntry.setLiveVal(VNoData.INSTANCE); + } i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), Alarm.none(), Time.now())); } }); diff --git a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java index 8fdf54e609..94d25dd2bd 100644 --- a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java +++ b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java @@ -30,7 +30,7 @@ public void start(Stage primaryStage) { Alarm.none(), Time.now(), Display.none()); ComparisonDialog comparisonDialog = - new ComparisonDialog(vDoubleArray, "loc://x(1, 8, 7, 0, -1)"); + new ComparisonDialog(vDoubleArray, "loc://x(1, 8, 7, 0)"); comparisonDialog.show(); } } From b97b54d6572e0cc34c861360aff6caba584756ef Mon Sep 17 00:00:00 2001 From: georgweiss Date: Fri, 5 Dec 2025 11:24:45 +0100 Subject: [PATCH 34/83] Proper handling of differences in array length in comparisoon dialog --- .../saveandrestore/ui/VTypePair.java | 9 +- .../ui/snapshot/compare/ColumnEntry.java | 11 +- .../TableComparisonViewController.java | 228 ++++++++++++------ 3 files changed, 165 insertions(+), 83 deletions(-) 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 c792272786..d653876b52 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 @@ -37,7 +37,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 @@ -52,13 +53,13 @@ public VTypePair(VType base, VType value, Optional> 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 - * absolute value to. + * an absolute value. * *

- * Main use case for this is ordering on delta. Absolute delta is more useful as otherwise zero + * 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. *

- * @return + * @return Absolute delta between {@link #base} and {@link #value}. */ public double getAbsoluteDelta(){ if(base == null || value == null){ 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 index 2745aeb44e..013356a54e 100644 --- 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 @@ -8,7 +8,6 @@ import javafx.beans.property.SimpleObjectProperty; import org.epics.vtype.VType; import org.phoebus.applications.saveandrestore.ui.VTypePair; -import org.phoebus.core.vtypes.VDisconnectedData; import org.phoebus.saveandrestore.util.Threshold; import org.phoebus.saveandrestore.util.VNoData; @@ -28,11 +27,11 @@ public class ColumnEntry { */ private final ObjectProperty delta = new SimpleObjectProperty<>(this, "delta", null); /** - * The libe {@link VType} value as read from a connected PV. + * 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(); + private final Optional> threshold = Optional.empty(); public ColumnEntry(VType storedValue) { this.storedValue.set(storedValue); @@ -42,9 +41,9 @@ public ObjectProperty storedValueProperty() { return storedValue; } - public void setLiveVal(VType value) { - liveValue.set(value); - VTypePair vTypePair = new VTypePair(storedValue.get(), value, threshold); + public void setLiveVal(VType liveValue) { + this.liveValue.set(liveValue); + VTypePair vTypePair = new VTypePair(liveValue, storedValue.get(), threshold); delta.set(vTypePair); } 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 index d0eba16a14..0940090242 100644 --- 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 @@ -5,14 +5,12 @@ package org.phoebus.applications.saveandrestore.ui.snapshot.compare; -import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.fxml.FXML; import javafx.scene.control.Label; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; -import org.epics.util.array.IteratorNumber; import org.epics.util.array.ListBoolean; import org.epics.vtype.Alarm; import org.epics.vtype.Display; @@ -23,7 +21,6 @@ import org.epics.vtype.VByteArray; import org.epics.vtype.VDouble; import org.epics.vtype.VDoubleArray; -import org.epics.vtype.VEnum; import org.epics.vtype.VEnumArray; import org.epics.vtype.VFloat; import org.epics.vtype.VFloatArray; @@ -49,6 +46,7 @@ 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; @@ -91,13 +89,13 @@ 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(cell.getValue().indexProperty().get()).storedValueProperty()); + cell.getValue().getColumnEntries().get(0).storedValueProperty()); storedValueColumn.setCellFactory(e -> new VTypeCellEditor<>()); liveValueColumn.setCellValueFactory(cell -> - cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).liveValueProperty()); + cell.getValue().getColumnEntries().get(0).liveValueProperty()); liveValueColumn.setCellFactory(e -> new VTypeCellEditor<>()); deltaColumn.setCellValueFactory(cell -> - cell.getValue().getColumnEntries().get(cell.getValue().indexProperty().get()).getDelta()); + cell.getValue().getColumnEntries().get(0).getDelta()); deltaColumn.setComparator(Comparator.comparingDouble(VTypePair::getAbsoluteDelta)); deltaColumn.setCellFactory(e -> new VDeltaCellEditor<>()); @@ -106,65 +104,76 @@ public void initialize() { public void loadDataAndConnect(VType data, String pvName) { pvNameProperty.set(pvName); - List columnEntries = new ArrayList<>(); + if (data instanceof VNumberArray) { - int index = 0; - IteratorNumber iteratorNumber = ((VNumberArray) data).getData().iterator(); - if (data instanceof VDoubleArray) { - while (iteratorNumber.hasNext()) { - double value = iteratorNumber.nextDouble(); + int arraySize = ((VNumberArray) data).getData().size(); + for (int index = 0; index < arraySize; index++) { + List columnEntries = new ArrayList<>(); + if (data instanceof VDoubleArray) { + double value = ((VDoubleArray) data).getData().getDouble(index); ColumnEntry columnEntry = new ColumnEntry(VDouble.of(value, Alarm.none(), Time.now(), Display.none())); - addRow(index, columnEntries, columnEntry); - index++; - } - } else if (data instanceof VFloatArray) { - while (iteratorNumber.hasNext()) { - float value = iteratorNumber.nextFloat(); + columnEntries.add(columnEntry); + ComparisonData comparisonData = new ComparisonData(index, columnEntries); + comparisonTable.getItems().add(index, comparisonData); + } else if (data instanceof VFloatArray) { + float value = ((VFloatArray) data).getData().getFloat(index); ColumnEntry columnEntry = new ColumnEntry(VFloat.of(value, Alarm.none(), Time.now(), Display.none())); - addRow(index, columnEntries, columnEntry); - index++; - } - } else if (data instanceof VIntArray) { - while (iteratorNumber.hasNext()) { - int value = iteratorNumber.nextInt(); + columnEntries.add(columnEntry); + ComparisonData comparisonData = new ComparisonData(index, columnEntries); + comparisonTable.getItems().add(index, comparisonData); + } else if (data instanceof VIntArray) { + int value = ((VIntArray) data).getData().getInt(index); ColumnEntry columnEntry = new ColumnEntry(VInt.of(value, Alarm.none(), Time.now(), Display.none())); - addRow(index, columnEntries, columnEntry); - index++; - } - } else if (data instanceof VLongArray) { - while (iteratorNumber.hasNext()) { - long value = iteratorNumber.nextLong(); + columnEntries.add(columnEntry); + ComparisonData comparisonData = new ComparisonData(index, columnEntries); + comparisonTable.getItems().add(index, comparisonData); + } else if (data instanceof VLongArray) { + long value = ((VLongArray) data).getData().getLong(index); ColumnEntry columnEntry = new ColumnEntry(VLong.of(value, Alarm.none(), Time.now(), Display.none())); - addRow(index, columnEntries, columnEntry); - index++; - } - } else if (data instanceof VShortArray) { - while (iteratorNumber.hasNext()) { - short value = iteratorNumber.nextShort(); + columnEntries.add(columnEntry); + ComparisonData comparisonData = new ComparisonData(index, columnEntries); + comparisonTable.getItems().add(index, comparisonData); + } else if (data instanceof VShortArray) { + short value = ((VShortArray) data).getData().getShort(index); ColumnEntry columnEntry = new ColumnEntry(VShort.of(value, Alarm.none(), Time.now(), Display.none())); - addRow(index, columnEntries, columnEntry); - index++; - } - } else if (data instanceof VByteArray) { - while (iteratorNumber.hasNext()) { - byte value = iteratorNumber.nextByte(); + columnEntries.add(columnEntry); + ComparisonData comparisonData = new ComparisonData(index, columnEntries); + comparisonTable.getItems().add(index, comparisonData); + } else if (data instanceof VByteArray) { + byte value = ((VByteArray) data).getData().getByte(index); ColumnEntry columnEntry = new ColumnEntry(VByte.of(value, Alarm.none(), Time.now(), Display.none())); - addRow(index, columnEntries, columnEntry); - index++; + columnEntries.add(columnEntry); + ComparisonData comparisonData = new ComparisonData(index, columnEntries); + comparisonTable.getItems().add(index, comparisonData); } } } else if (data instanceof VBooleanArray) { ListBoolean listBoolean = ((VBooleanArray) data).getData(); - for (int i = 0; i < listBoolean.size(); i++) { - boolean value = listBoolean.getBoolean(i); + for (int index = 0; index < listBoolean.size(); index++) { + List columnEntries = new ArrayList<>(); + boolean value = listBoolean.getBoolean(index); ColumnEntry columnEntry = new ColumnEntry(VBoolean.of(value, Alarm.none(), Time.now())); - addRow(i, columnEntries, columnEntry); + columnEntries.add(columnEntry); + ComparisonData comparisonData = new ComparisonData(index, columnEntries); + comparisonTable.getItems().add(index, comparisonData); } } else if (data instanceof VEnumArray) { List enumValues = ((VEnumArray) data).getData(); - for (int i = 0; i < enumValues.size(); i++) { - ColumnEntry columnEntry = new ColumnEntry(VString.of(enumValues.get(i), Alarm.none(), Time.now())); - addRow(i, columnEntries, columnEntry); + for (int index = 0; index < enumValues.size(); index++) { + List columnEntries = new ArrayList<>(); + ColumnEntry columnEntry = new ColumnEntry(VString.of(enumValues.get(index), Alarm.none(), Time.now())); + columnEntries.add(columnEntry); + ComparisonData comparisonData = new ComparisonData(index, columnEntries); + comparisonTable.getItems().add(index, comparisonData); + } + } else if (data instanceof VStringArray) { + List stringValues = ((VStringArray) data).getData(); + for (int index = 0; index < stringValues.size(); index++) { + List columnEntries = new ArrayList<>(); + ColumnEntry columnEntry = new ColumnEntry(VString.of(stringValues.get(index), Alarm.none(), Time.now())); + columnEntries.add(columnEntry); + ComparisonData comparisonData = new ComparisonData(index, columnEntries); + comparisonTable.getItems().add(index, comparisonData); } } @@ -192,47 +201,120 @@ private void updateTable(VType liveData) { if (liveData.equals(VDisconnectedData.INSTANCE)) { comparisonTable.getItems().forEach(i -> i.getColumnEntries().get(0).setLiveVal(VDisconnectedData.INSTANCE)); } else { + AtomicInteger liveDataArraySize = new AtomicInteger(0); comparisonTable.getItems().forEach(i -> { int index = i.indexProperty().get(); - ColumnEntry columnEntry = i.getColumnEntries().get(index); - if(liveData instanceof VNumberArray){ - if(index >= ((VNumberArray)liveData).getData().size()){ + ColumnEntry columnEntry = i.getColumnEntries().get(0); + if (liveData instanceof VNumberArray) { + liveDataArraySize.set(((VNumberArray) liveData).getData().size()); + if (index >= liveDataArraySize.get()) { // 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), Alarm.none(), Time.now(), Display.none())); - } else if (liveData instanceof VIntArray) { - VIntArray array = (VIntArray) liveData; + } else if (liveData instanceof VIntArray array) { columnEntry.setLiveVal(VInt.of(array.getData().getInt(index), Alarm.none(), Time.now(), Display.none())); - } else if (liveData instanceof VLongArray) { - VLongArray array = (VLongArray) liveData; + } else if (liveData instanceof VLongArray array) { columnEntry.setLiveVal(VLong.of(array.getData().getLong(index), Alarm.none(), Time.now(), Display.none())); - } else if (liveData instanceof VFloatArray) { - VFloatArray array = (VFloatArray) liveData; + } else if (liveData instanceof VFloatArray array) { columnEntry.setLiveVal(VFloat.of(array.getData().getFloat(index), Alarm.none(), Time.now(), Display.none())); - } else if (liveData instanceof VShortArray) { - VShortArray array = (VShortArray) liveData; + } else if (liveData instanceof VShortArray array) { columnEntry.setLiveVal(VShort.of(array.getData().getShort(index), Alarm.none(), Time.now(), Display.none())); } - } else if (liveData instanceof VBooleanArray) { - VBooleanArray array = (VBooleanArray)liveData; - if(index > array.getData().size()){ + } else if (liveData instanceof VBooleanArray array) { + liveDataArraySize.set(array.getData().size()); + 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), Alarm.none(), Time.now())); } - columnEntry.setLiveVal(VBoolean.of(array.getData().getBoolean(index), Alarm.none(), Time.now())); - } else if (liveData instanceof VEnumArray) { - VEnumArray array = (VEnumArray) liveData; - if(index > array.getData().size()){ + + } else if (liveData instanceof VEnumArray array) { + liveDataArraySize.set(array.getData().size()); + if (index >= array.getData().size()) { // Live data has fewer elements than stored data columnEntry.setLiveVal(VNoData.INSTANCE); + } else { + i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), Alarm.none(), Time.now())); } - i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), Alarm.none(), Time.now())); - } else if (liveData instanceof VStringArray) { - VStringArray array = (VStringArray) liveData; - if(index > array.getData().size()){ + } else if (liveData instanceof VStringArray array) { + liveDataArraySize.set(array.getData().size()); + if (index >= array.getData().size()) { // Live data has fewer elements than stored data columnEntry.setLiveVal(VNoData.INSTANCE); + } else { + i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), Alarm.none(), Time.now())); } - i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), Alarm.none(), Time.now())); } }); + // Live data may have more elements than stored data + if (liveDataArraySize.get() > comparisonTable.getItems().size()) { + List columnEntries = new ArrayList<>(); + if (liveData instanceof VNumberArray) { + if (liveData instanceof VDoubleArray) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + double value = ((VDoubleArray) liveData).getData().getDouble(index); + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VDouble.of(value, Alarm.none(), Time.now(), Display.none())); + addRow(index, columnEntries, columnEntry); + } + } else if (liveData instanceof VFloatArray) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + float value = ((VFloatArray) liveData).getData().getFloat(index); + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VFloat.of(value, Alarm.none(), Time.now(), Display.none())); + addRow(index, columnEntries, columnEntry); + } + } else if (liveData instanceof VIntArray) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + int value = ((VIntArray) liveData).getData().getInt(index); + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VInt.of(value, Alarm.none(), Time.now(), Display.none())); + addRow(index, columnEntries, columnEntry); + } + } else if (liveData instanceof VLongArray) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + long value = ((VLongArray) liveData).getData().getLong(index); + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VLong.of(value, Alarm.none(), Time.now(), Display.none())); + addRow(index, columnEntries, columnEntry); + } + } else if (liveData instanceof VShortArray) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + short value = ((VShortArray) liveData).getData().getShort(index); + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VShort.of(value, Alarm.none(), Time.now(), Display.none())); + addRow(index, columnEntries, columnEntry); + } + } else if (liveData instanceof VByteArray) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + byte value = ((VByteArray) liveData).getData().getByte(index); + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VByte.of(value, Alarm.none(), Time.now(), Display.none())); + addRow(index, columnEntries, columnEntry); + } + } + } else if (liveData instanceof VBooleanArray) { + ListBoolean listBoolean = ((VBooleanArray) liveData).getData(); + for (int i = 0; i < listBoolean.size(); i++) { + boolean value = listBoolean.getBoolean(i); + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VBoolean.of(value, Alarm.none(), Time.now())); + addRow(i, columnEntries, columnEntry); + } + } else if (liveData instanceof VEnumArray) { + List enumValues = ((VEnumArray) liveData).getData(); + for (int i = 0; i < enumValues.size(); i++) { + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VString.of(enumValues.get(i), Alarm.none(), Time.now())); + addRow(i, columnEntries, columnEntry); + } + } else if (liveData instanceof VStringArray) { + List stringValues = ((VStringArray) liveData).getData(); + for (int i = 0; i < stringValues.size(); i++) { + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VString.of(stringValues.get(i), Alarm.none(), Time.now())); + addRow(i, columnEntries, columnEntry); + } + } + } } } } From 0024eede3e93175b4f64f6d6a099785f67121c58 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Fri, 5 Dec 2025 13:35:13 +0100 Subject: [PATCH 35/83] Minor layout and code cleanup changes --- .../TableComparisonViewController.java | 124 +++++++++--------- .../snapshot/compare/TableComparisonView.fxml | 2 +- 2 files changed, 63 insertions(+), 63 deletions(-) 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 index 0940090242..2ca880c62b 100644 --- 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 @@ -109,68 +109,68 @@ public void loadDataAndConnect(VType data, String pvName) { int arraySize = ((VNumberArray) data).getData().size(); for (int index = 0; index < arraySize; index++) { List columnEntries = new ArrayList<>(); - if (data instanceof VDoubleArray) { - double value = ((VDoubleArray) data).getData().getDouble(index); - ColumnEntry columnEntry = new ColumnEntry(VDouble.of(value, Alarm.none(), Time.now(), Display.none())); + if (data instanceof VDoubleArray array) { + double value = array.getData().getDouble(index); + ColumnEntry columnEntry = new ColumnEntry(VDouble.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); columnEntries.add(columnEntry); ComparisonData comparisonData = new ComparisonData(index, columnEntries); comparisonTable.getItems().add(index, comparisonData); - } else if (data instanceof VFloatArray) { - float value = ((VFloatArray) data).getData().getFloat(index); - ColumnEntry columnEntry = new ColumnEntry(VFloat.of(value, Alarm.none(), Time.now(), Display.none())); + } else if (data instanceof VFloatArray array) { + float value = array.getData().getFloat(index); + ColumnEntry columnEntry = new ColumnEntry(VFloat.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); columnEntries.add(columnEntry); ComparisonData comparisonData = new ComparisonData(index, columnEntries); comparisonTable.getItems().add(index, comparisonData); - } else if (data instanceof VIntArray) { - int value = ((VIntArray) data).getData().getInt(index); - ColumnEntry columnEntry = new ColumnEntry(VInt.of(value, Alarm.none(), Time.now(), Display.none())); + } else if (data instanceof VIntArray array) { + int value = array.getData().getInt(index); + ColumnEntry columnEntry = new ColumnEntry(VInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); columnEntries.add(columnEntry); ComparisonData comparisonData = new ComparisonData(index, columnEntries); comparisonTable.getItems().add(index, comparisonData); - } else if (data instanceof VLongArray) { - long value = ((VLongArray) data).getData().getLong(index); - ColumnEntry columnEntry = new ColumnEntry(VLong.of(value, Alarm.none(), Time.now(), Display.none())); + } else if (data instanceof VLongArray array) { + long value = array.getData().getLong(index); + ColumnEntry columnEntry = new ColumnEntry(VLong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); columnEntries.add(columnEntry); ComparisonData comparisonData = new ComparisonData(index, columnEntries); comparisonTable.getItems().add(index, comparisonData); - } else if (data instanceof VShortArray) { - short value = ((VShortArray) data).getData().getShort(index); - ColumnEntry columnEntry = new ColumnEntry(VShort.of(value, Alarm.none(), Time.now(), Display.none())); + } else if (data instanceof VShortArray array) { + short value = array.getData().getShort(index); + ColumnEntry columnEntry = new ColumnEntry(VShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); columnEntries.add(columnEntry); ComparisonData comparisonData = new ComparisonData(index, columnEntries); comparisonTable.getItems().add(index, comparisonData); - } else if (data instanceof VByteArray) { - byte value = ((VByteArray) data).getData().getByte(index); - ColumnEntry columnEntry = new ColumnEntry(VByte.of(value, Alarm.none(), Time.now(), Display.none())); + } else if (data instanceof VByteArray array) { + byte value = array.getData().getByte(index); + ColumnEntry columnEntry = new ColumnEntry(VByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); columnEntries.add(columnEntry); ComparisonData comparisonData = new ComparisonData(index, columnEntries); comparisonTable.getItems().add(index, comparisonData); } } - } else if (data instanceof VBooleanArray) { - ListBoolean listBoolean = ((VBooleanArray) data).getData(); + } else if (data instanceof VBooleanArray array) { + ListBoolean listBoolean = array.getData(); for (int index = 0; index < listBoolean.size(); index++) { List columnEntries = new ArrayList<>(); boolean value = listBoolean.getBoolean(index); - ColumnEntry columnEntry = new ColumnEntry(VBoolean.of(value, Alarm.none(), Time.now())); + ColumnEntry columnEntry = new ColumnEntry(VBoolean.of(value, array.getAlarm(), array.getTime())); columnEntries.add(columnEntry); ComparisonData comparisonData = new ComparisonData(index, columnEntries); comparisonTable.getItems().add(index, comparisonData); } - } else if (data instanceof VEnumArray) { - List enumValues = ((VEnumArray) data).getData(); + } else if (data instanceof VEnumArray array) { + List enumValues = array.getData(); for (int index = 0; index < enumValues.size(); index++) { List columnEntries = new ArrayList<>(); - ColumnEntry columnEntry = new ColumnEntry(VString.of(enumValues.get(index), Alarm.none(), Time.now())); + ColumnEntry columnEntry = new ColumnEntry(VString.of(enumValues.get(index), array.getAlarm(), array.getTime())); columnEntries.add(columnEntry); ComparisonData comparisonData = new ComparisonData(index, columnEntries); comparisonTable.getItems().add(index, comparisonData); } - } else if (data instanceof VStringArray) { - List stringValues = ((VStringArray) data).getData(); + } else if (data instanceof VStringArray array) { + List stringValues = array.getData(); for (int index = 0; index < stringValues.size(); index++) { List columnEntries = new ArrayList<>(); - ColumnEntry columnEntry = new ColumnEntry(VString.of(stringValues.get(index), Alarm.none(), Time.now())); + ColumnEntry columnEntry = new ColumnEntry(VString.of(stringValues.get(index), array.getAlarm(), array.getTime())); columnEntries.add(columnEntry); ComparisonData comparisonData = new ComparisonData(index, columnEntries); comparisonTable.getItems().add(index, comparisonData); @@ -210,22 +210,22 @@ private void updateTable(VType liveData) { if (index >= liveDataArraySize.get()) { // 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), Alarm.none(), Time.now(), Display.none())); + columnEntry.setLiveVal(VDouble.of(array.getData().getDouble(index), array.getAlarm(), array.getTime(), array.getDisplay())); } else if (liveData instanceof VIntArray array) { - columnEntry.setLiveVal(VInt.of(array.getData().getInt(index), Alarm.none(), Time.now(), Display.none())); + columnEntry.setLiveVal(VInt.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), Alarm.none(), Time.now(), Display.none())); + columnEntry.setLiveVal(VLong.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), Alarm.none(), Time.now(), Display.none())); + columnEntry.setLiveVal(VFloat.of(array.getData().getFloat(index), array.getAlarm(), array.getTime(), array.getDisplay())); } else if (liveData instanceof VShortArray array) { - columnEntry.setLiveVal(VShort.of(array.getData().getShort(index), Alarm.none(), Time.now(), Display.none())); + columnEntry.setLiveVal(VShort.of(array.getData().getShort(index),array.getAlarm(), array.getTime(), array.getDisplay())); } } else if (liveData instanceof VBooleanArray array) { liveDataArraySize.set(array.getData().size()); 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), Alarm.none(), Time.now())); + columnEntry.setLiveVal(VBoolean.of(array.getData().getBoolean(index), array.getAlarm(), array.getTime())); } } else if (liveData instanceof VEnumArray array) { @@ -233,14 +233,14 @@ private void updateTable(VType liveData) { if (index >= array.getData().size()) { // Live data has fewer elements than stored data columnEntry.setLiveVal(VNoData.INSTANCE); } else { - i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), Alarm.none(), Time.now())); + i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), array.getAlarm(), array.getTime())); } } else if (liveData instanceof VStringArray array) { liveDataArraySize.set(array.getData().size()); if (index >= array.getData().size()) { // Live data has fewer elements than stored data columnEntry.setLiveVal(VNoData.INSTANCE); } else { - i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), Alarm.none(), Time.now())); + i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), array.getAlarm(), array.getTime())); } } }); @@ -248,69 +248,69 @@ private void updateTable(VType liveData) { if (liveDataArraySize.get() > comparisonTable.getItems().size()) { List columnEntries = new ArrayList<>(); if (liveData instanceof VNumberArray) { - if (liveData instanceof VDoubleArray) { + if (liveData instanceof VDoubleArray array) { for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { - double value = ((VDoubleArray) liveData).getData().getDouble(index); + double value = array.getData().getDouble(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); - columnEntry.setLiveVal(VDouble.of(value, Alarm.none(), Time.now(), Display.none())); + columnEntry.setLiveVal(VDouble.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } - } else if (liveData instanceof VFloatArray) { + } else if (liveData instanceof VFloatArray array) { for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { - float value = ((VFloatArray) liveData).getData().getFloat(index); + float value = array.getData().getFloat(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); - columnEntry.setLiveVal(VFloat.of(value, Alarm.none(), Time.now(), Display.none())); + columnEntry.setLiveVal(VFloat.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } - } else if (liveData instanceof VIntArray) { + } else if (liveData instanceof VIntArray array) { for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { - int value = ((VIntArray) liveData).getData().getInt(index); + int value = array.getData().getInt(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); - columnEntry.setLiveVal(VInt.of(value, Alarm.none(), Time.now(), Display.none())); + columnEntry.setLiveVal(VInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } - } else if (liveData instanceof VLongArray) { + } else if (liveData instanceof VLongArray array) { for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { - long value = ((VLongArray) liveData).getData().getLong(index); + long value = array.getData().getLong(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); - columnEntry.setLiveVal(VLong.of(value, Alarm.none(), Time.now(), Display.none())); + columnEntry.setLiveVal(VLong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } - } else if (liveData instanceof VShortArray) { + } else if (liveData instanceof VShortArray array) { for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { - short value = ((VShortArray) liveData).getData().getShort(index); + short value = array.getData().getShort(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); - columnEntry.setLiveVal(VShort.of(value, Alarm.none(), Time.now(), Display.none())); + columnEntry.setLiveVal(VShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } - } else if (liveData instanceof VByteArray) { + } else if (liveData instanceof VByteArray array) { for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { - byte value = ((VByteArray) liveData).getData().getByte(index); + byte value = array.getData().getByte(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); - columnEntry.setLiveVal(VByte.of(value, Alarm.none(), Time.now(), Display.none())); + columnEntry.setLiveVal(VByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } } - } else if (liveData instanceof VBooleanArray) { - ListBoolean listBoolean = ((VBooleanArray) liveData).getData(); + } else if (liveData instanceof VBooleanArray array) { + ListBoolean listBoolean = array.getData(); for (int i = 0; i < listBoolean.size(); i++) { boolean value = listBoolean.getBoolean(i); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); - columnEntry.setLiveVal(VBoolean.of(value, Alarm.none(), Time.now())); + columnEntry.setLiveVal(VBoolean.of(value, array.getAlarm(), array.getTime())); addRow(i, columnEntries, columnEntry); } - } else if (liveData instanceof VEnumArray) { - List enumValues = ((VEnumArray) liveData).getData(); + } else if (liveData instanceof VEnumArray array) { + List enumValues = array.getData(); for (int i = 0; i < enumValues.size(); i++) { ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); - columnEntry.setLiveVal(VString.of(enumValues.get(i), Alarm.none(), Time.now())); + columnEntry.setLiveVal(VString.of(enumValues.get(i), array.getAlarm(), array.getTime())); addRow(i, columnEntries, columnEntry); } - } else if (liveData instanceof VStringArray) { - List stringValues = ((VStringArray) liveData).getData(); + } else if (liveData instanceof VStringArray array) { + List stringValues = array.getData(); for (int i = 0; i < stringValues.size(); i++) { ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); - columnEntry.setLiveVal(VString.of(stringValues.get(i), Alarm.none(), Time.now())); + columnEntry.setLiveVal(VString.of(stringValues.get(i), array.getAlarm(), array.getTime())); addRow(i, columnEntries, columnEntry); } } 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 index bc723e2414..e22713ea2e 100644 --- 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 @@ -9,7 +9,7 @@
- + From 9a8d40cdbba3966c81e466bde8dde35ec26673a7 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Fri, 5 Dec 2025 14:27:42 +0100 Subject: [PATCH 36/83] Javadoc and code cleanup --- .../ui/snapshot/compare/ComparisonData.java | 29 +++++---- .../ui/snapshot/compare/ComparisonDialog.java | 19 ++++++ .../TableComparisonViewController.java | 61 ++++++++----------- 3 files changed, 60 insertions(+), 49 deletions(-) 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 index 836c0416f6..42b0e7db10 100644 --- 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 @@ -6,26 +6,25 @@ import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleIntegerProperty; -import org.epics.vtype.VBooleanArray; -import org.epics.vtype.VByteArray; -import org.epics.vtype.VDoubleArray; -import org.epics.vtype.VFloatArray; -import org.epics.vtype.VIntArray; -import org.epics.vtype.VLongArray; -import org.epics.vtype.VNumberArray; -import org.epics.vtype.VShortArray; -import org.epics.vtype.VTable; -import org.epics.vtype.VType; - -import java.util.ArrayList; + import java.util.List; +/** + * 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"); - private List columnEntries; + /** + * {@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){ + public ComparisonData(int index, List columnEntries) { this.index.set(index); this.columnEntries = columnEntries; } @@ -35,7 +34,7 @@ public IntegerProperty indexProperty() { return index; } - public List getColumnEntries(){ + public List getColumnEntries() { return columnEntries; } } 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 index 3af5d045bc..442dd2a170 100644 --- 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 @@ -15,8 +15,27 @@ 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); 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 index 2ca880c62b..2aa502fe7a 100644 --- 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 @@ -12,9 +12,6 @@ import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import org.epics.util.array.ListBoolean; -import org.epics.vtype.Alarm; -import org.epics.vtype.Display; -import org.epics.vtype.Time; import org.epics.vtype.VBoolean; import org.epics.vtype.VBooleanArray; import org.epics.vtype.VByte; @@ -50,6 +47,9 @@ import java.util.logging.Level; import java.util.logging.Logger; +/** + * Controller class for the comparison table view. + */ public class TableComparisonViewController { @SuppressWarnings("unused") @@ -101,6 +101,11 @@ public void initialize() { deltaColumn.setCellFactory(e -> new VDeltaCellEditor<>()); } + /** + * 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); @@ -112,39 +117,27 @@ public void loadDataAndConnect(VType data, String pvName) { if (data instanceof VDoubleArray array) { double value = array.getData().getDouble(index); ColumnEntry columnEntry = new ColumnEntry(VDouble.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - columnEntries.add(columnEntry); - ComparisonData comparisonData = new ComparisonData(index, columnEntries); - comparisonTable.getItems().add(index, comparisonData); + addRow(index, columnEntries, columnEntry); } else if (data instanceof VFloatArray array) { float value = array.getData().getFloat(index); ColumnEntry columnEntry = new ColumnEntry(VFloat.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - columnEntries.add(columnEntry); - ComparisonData comparisonData = new ComparisonData(index, columnEntries); - comparisonTable.getItems().add(index, comparisonData); + addRow(index, columnEntries, columnEntry); } else if (data instanceof VIntArray array) { int value = array.getData().getInt(index); ColumnEntry columnEntry = new ColumnEntry(VInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - columnEntries.add(columnEntry); - ComparisonData comparisonData = new ComparisonData(index, columnEntries); - comparisonTable.getItems().add(index, comparisonData); + addRow(index, columnEntries, columnEntry); } else if (data instanceof VLongArray array) { long value = array.getData().getLong(index); ColumnEntry columnEntry = new ColumnEntry(VLong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - columnEntries.add(columnEntry); - ComparisonData comparisonData = new ComparisonData(index, columnEntries); - comparisonTable.getItems().add(index, comparisonData); + addRow(index, columnEntries, columnEntry); } else if (data instanceof VShortArray array) { short value = array.getData().getShort(index); ColumnEntry columnEntry = new ColumnEntry(VShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - columnEntries.add(columnEntry); - ComparisonData comparisonData = new ComparisonData(index, columnEntries); - comparisonTable.getItems().add(index, comparisonData); + addRow(index, columnEntries, columnEntry); } else if (data instanceof VByteArray array) { byte value = array.getData().getByte(index); ColumnEntry columnEntry = new ColumnEntry(VByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - columnEntries.add(columnEntry); - ComparisonData comparisonData = new ComparisonData(index, columnEntries); - comparisonTable.getItems().add(index, comparisonData); + addRow(index, columnEntries, columnEntry); } } } else if (data instanceof VBooleanArray array) { @@ -153,32 +146,24 @@ public void loadDataAndConnect(VType data, String pvName) { List columnEntries = new ArrayList<>(); boolean value = listBoolean.getBoolean(index); ColumnEntry columnEntry = new ColumnEntry(VBoolean.of(value, array.getAlarm(), array.getTime())); - columnEntries.add(columnEntry); - ComparisonData comparisonData = new ComparisonData(index, columnEntries); - comparisonTable.getItems().add(index, comparisonData); + addRow(index, columnEntries, columnEntry); } } else if (data instanceof VEnumArray array) { List enumValues = array.getData(); for (int index = 0; index < enumValues.size(); index++) { List columnEntries = new ArrayList<>(); ColumnEntry columnEntry = new ColumnEntry(VString.of(enumValues.get(index), array.getAlarm(), array.getTime())); - columnEntries.add(columnEntry); - ComparisonData comparisonData = new ComparisonData(index, columnEntries); - comparisonTable.getItems().add(index, comparisonData); + addRow(index, columnEntries, columnEntry); } } else if (data instanceof VStringArray array) { List stringValues = array.getData(); for (int index = 0; index < stringValues.size(); index++) { List columnEntries = new ArrayList<>(); ColumnEntry columnEntry = new ColumnEntry(VString.of(stringValues.get(index), array.getAlarm(), array.getTime())); - columnEntries.add(columnEntry); - ComparisonData comparisonData = new ComparisonData(index, columnEntries); - comparisonTable.getItems().add(index, comparisonData); + addRow(index, columnEntries, columnEntry); } } - connect(); - } private void addRow(int index, List columnEntries, ColumnEntry columnEntry) { @@ -187,7 +172,10 @@ private void addRow(int index, List columnEntries, ColumnEntry colu comparisonTable.getItems().add(index, comparisonData); } - public void connect() { + /** + * Attempts to connect to the PV. + */ + private void connect() { try { PV pv = PVPool.getPV(pvNameProperty.get()); pv.onValueEvent().throttleLatest(TABLE_UPDATE_INTERVAL, TimeUnit.MILLISECONDS) @@ -197,6 +185,11 @@ public void connect() { } } + /** + * 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)); @@ -218,7 +211,7 @@ private void updateTable(VType liveData) { } 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(VShort.of(array.getData().getShort(index),array.getAlarm(), array.getTime(), array.getDisplay())); + columnEntry.setLiveVal(VShort.of(array.getData().getShort(index), array.getAlarm(), array.getTime(), array.getDisplay())); } } else if (liveData instanceof VBooleanArray array) { liveDataArraySize.set(array.getData().size()); From 21e8517496ee2a39cb4d49f243a62c3970df2ee5 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Mon, 8 Dec 2025 09:11:49 +0100 Subject: [PATCH 37/83] Support for setting threshold when comparing array/table elements --- .../ui/snapshot/VDeltaCellEditor.java | 5 +- .../ui/snapshot/compare/ColumnEntry.java | 20 +++++++- .../ui/snapshot/compare/ComparisonData.java | 15 ++++++ .../TableComparisonViewController.java | 48 ++++++++++++++++++- .../snapshot/compare/TableComparisonView.fxml | 20 +++++--- .../compare/ComparisonDialogDemo.java | 4 +- 6 files changed, 99 insertions(+), 13 deletions(-) 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 07e5840430..e072c002d0 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 @@ -78,7 +78,10 @@ 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); if (vtc.getValuesEqual() != 0 && (pair.base instanceof VNumberArray || 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 index 013356a54e..6246262ab3 100644 --- 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 @@ -6,9 +6,12 @@ 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; @@ -31,7 +34,7 @@ public class ColumnEntry { */ private final ObjectProperty liveValue = new SimpleObjectProperty<>(this, "liveValue", VNoData.INSTANCE); - private final Optional> threshold = Optional.empty(); + private Optional> threshold = Optional.empty(); public ColumnEntry(VType storedValue) { this.storedValue.set(storedValue); @@ -54,4 +57,19 @@ public ObjectProperty liveValueProperty() { 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 index 42b0e7db10..7026add0d2 100644 --- 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 @@ -6,8 +6,13 @@ 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. @@ -37,4 +42,14 @@ public IntegerProperty indexProperty() { 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/TableComparisonViewController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonViewController.java index 2aa502fe7a..c61bdcb60e 100644 --- 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 @@ -8,9 +8,14 @@ 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; @@ -31,6 +36,7 @@ import org.epics.vtype.VString; import org.epics.vtype.VStringArray; import org.epics.vtype.VType; +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; @@ -68,11 +74,14 @@ public class TableComparisonViewController { @FXML private TableColumn liveValueColumn; - @SuppressWarnings("unused") @FXML private TableColumn deltaColumn; + @SuppressWarnings("unused") + @FXML + private Spinner thresholdSpinner; + @SuppressWarnings("unused") @FXML private Label pvName; @@ -99,11 +108,18 @@ public void initialize() { 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)); } /** * 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 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) { @@ -188,6 +204,7 @@ private void connect() { /** * 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) { @@ -310,4 +327,31 @@ private void updateTable(VType liveData) { } } } + + private void parseAndUpdateThreshold(String value) { + thresholdSpinner.getEditor().getStyleClass().remove("input-error"); + thresholdSpinner.setTooltip(null); + + double parsedNumber; + try { + 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 scalar data types. The threshold is used to indicate that a delta value within threshold + * should not decorate the delta column, i.e. consider saved and live values equal. + * + * @param threshold Threshold in percent + */ + private void updateThreshold(double threshold) { + double ratio = threshold / 100; + comparisonTable.getItems().forEach(comparisonData -> { + comparisonData.setThreshold(ratio); + }); + } } 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 index e22713ea2e..19099a9ca9 100644 --- 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 @@ -5,7 +5,19 @@ + + + + + +
@@ -24,11 +36,5 @@
- - - +
diff --git a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java index 94d25dd2bd..4562d0fae8 100644 --- a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java +++ b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java @@ -26,11 +26,11 @@ public static void main(String[] args) { public void start(Stage primaryStage) { VDoubleArray vDoubleArray = - VDoubleArray.of(ArrayDouble.of(1, 2, 3, 4, 5), + VDoubleArray.of(ArrayDouble.of(1, 2, 3), Alarm.none(), Time.now(), Display.none()); ComparisonDialog comparisonDialog = - new ComparisonDialog(vDoubleArray, "loc://x(1, 8, 7, 0)"); + new ComparisonDialog(vDoubleArray, "loc://x(1, 1.9, 4, 8)"); comparisonDialog.show(); } } From 357bdd865e25a34b9d25d2dfc2378bb982e6ab57 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Mon, 8 Dec 2025 09:19:04 +0100 Subject: [PATCH 38/83] Cleanup code when comparison dialog is closed --- .../ui/snapshot/compare/ComparisonDialog.java | 1 + .../compare/TableComparisonViewController.java | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) 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 index 442dd2a170..9f531b8e4f 100644 --- 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 @@ -51,6 +51,7 @@ public ComparisonDialog(VType data, String pvName){ 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 index c61bdcb60e..2cb6cbce76 100644 --- 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 @@ -88,6 +88,8 @@ public class TableComparisonViewController { private final StringProperty pvNameProperty = new SimpleStringProperty(); + private PV pv; + /** * The time between updates of dynamic data in the table, in ms. */ @@ -193,7 +195,7 @@ private void addRow(int index, List columnEntries, ColumnEntry colu */ private void connect() { try { - PV pv = PVPool.getPV(pvNameProperty.get()); + 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) { @@ -201,6 +203,15 @@ private void connect() { } } + /** + * 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. From fa7bb3c8fe8ee613fa2d1d18efe63b30adb6c653 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Mon, 8 Dec 2025 11:32:16 +0100 Subject: [PATCH 39/83] Use VTypeHelper to determine array size --- .../TableComparisonViewController.java | 107 ++++++++++++++---- .../compare/ComparisonDialogDemo.java | 2 +- .../org/phoebus/core/vtypes/VTypeHelper.java | 2 + .../phoebus/core/vtypes/VTypeHelperTest.java | 4 + 4 files changed, 89 insertions(+), 26 deletions(-) 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 index 2cb6cbce76..5d4c86f3f0 100644 --- 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 @@ -36,11 +36,20 @@ 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.VNoData; @@ -49,7 +58,6 @@ 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; @@ -128,8 +136,8 @@ public void loadDataAndConnect(VType data, String pvName) { pvNameProperty.set(pvName); + int arraySize = VTypeHelper.getArraySize(data); if (data instanceof VNumberArray) { - int arraySize = ((VNumberArray) data).getData().size(); for (int index = 0; index < arraySize; index++) { List columnEntries = new ArrayList<>(); if (data instanceof VDoubleArray array) { @@ -144,18 +152,34 @@ public void loadDataAndConnect(VType data, String pvName) { int value = array.getData().getInt(index); ColumnEntry columnEntry = new ColumnEntry(VInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); + } else if (data instanceof VUIntArray array) { + int value = array.getData().getInt(index); + ColumnEntry columnEntry = new ColumnEntry(VUInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + addRow(index, columnEntries, columnEntry); } else if (data instanceof VLongArray array) { long value = array.getData().getLong(index); ColumnEntry columnEntry = new ColumnEntry(VLong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); + } else if (data instanceof VULongArray array) { + long value = array.getData().getLong(index); + ColumnEntry columnEntry = new ColumnEntry(VULong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + addRow(index, columnEntries, columnEntry); } else if (data instanceof VShortArray array) { short value = array.getData().getShort(index); ColumnEntry columnEntry = new ColumnEntry(VShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); + } else if (data instanceof VUShortArray array) { + short value = array.getData().getShort(index); + ColumnEntry columnEntry = new ColumnEntry(VUShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + addRow(index, columnEntries, columnEntry); } else if (data instanceof VByteArray array) { byte value = array.getData().getByte(index); ColumnEntry columnEntry = new ColumnEntry(VByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); + } else if (data instanceof VUByteArray array) { + byte value = array.getData().getByte(index); + ColumnEntry columnEntry = new ColumnEntry(VUByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + addRow(index, columnEntries, columnEntry); } } } else if (data instanceof VBooleanArray array) { @@ -206,8 +230,8 @@ private void connect() { /** * Returns PV to pool, e.g. when UI is dismissed. */ - public void cleanUp(){ - if(pv != null){ + public void cleanUp() { + if (pv != null) { PVPool.releasePV(pv); } } @@ -222,95 +246,130 @@ private void updateTable(VType liveData) { if (liveData.equals(VDisconnectedData.INSTANCE)) { comparisonTable.getItems().forEach(i -> i.getColumnEntries().get(0).setLiveVal(VDisconnectedData.INSTANCE)); } else { - AtomicInteger liveDataArraySize = new AtomicInteger(0); + int liveDataArraySize = VTypeHelper.getArraySize(liveData); comparisonTable.getItems().forEach(i -> { int index = i.indexProperty().get(); ColumnEntry columnEntry = i.getColumnEntries().get(0); if (liveData instanceof VNumberArray) { - liveDataArraySize.set(((VNumberArray) liveData).getData().size()); - if (index >= liveDataArraySize.get()) { // Live data has fewer elements than stored data + 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) { - liveDataArraySize.set(array.getData().size()); 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) { - liveDataArraySize.set(array.getData().size()); if (index >= array.getData().size()) { // Live data has fewer elements than stored data columnEntry.setLiveVal(VNoData.INSTANCE); } else { - i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), array.getAlarm(), array.getTime())); + columnEntry.setLiveVal(VString.of(array.getData().get(index), array.getAlarm(), array.getTime())); } } else if (liveData instanceof VStringArray array) { - liveDataArraySize.set(array.getData().size()); if (index >= array.getData().size()) { // Live data has fewer elements than stored data columnEntry.setLiveVal(VNoData.INSTANCE); } else { - i.getColumnEntries().get(index).setLiveVal(VString.of(array.getData().get(index), array.getAlarm(), array.getTime())); + columnEntry.setLiveVal(VString.of(array.getData().get(index), array.getAlarm(), array.getTime())); } } }); // Live data may have more elements than stored data - if (liveDataArraySize.get() > comparisonTable.getItems().size()) { + if (liveDataArraySize > comparisonTable.getItems().size()) { List columnEntries = new ArrayList<>(); if (liveData instanceof VNumberArray) { if (liveData instanceof VDoubleArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { double value = array.getData().getDouble(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VDouble.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } } else if (liveData instanceof VFloatArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { float value = array.getData().getFloat(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VFloat.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } } else if (liveData instanceof VIntArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { int value = array.getData().getInt(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } + } else if (liveData instanceof VUIntArray array) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + int value = array.getData().getInt(index); + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VUInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + addRow(index, columnEntries, columnEntry); + } } else if (liveData instanceof VLongArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { long value = array.getData().getLong(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VLong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } + } else if (liveData instanceof VULongArray array) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + long value = array.getData().getLong(index); + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VULong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + addRow(index, columnEntries, columnEntry); + } } else if (liveData instanceof VShortArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { short value = array.getData().getShort(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } + } else if (liveData instanceof VUShortArray array) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + short value = array.getData().getShort(index); + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VUShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + addRow(index, columnEntries, columnEntry); + } } else if (liveData instanceof VByteArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize.get(); index++) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { byte value = array.getData().getByte(index); ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); addRow(index, columnEntries, columnEntry); } + } else if (liveData instanceof VUByteArray array) { + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + byte value = array.getData().getByte(index); + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + columnEntry.setLiveVal(VUByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + addRow(index, columnEntries, columnEntry); + } } } else if (liveData instanceof VBooleanArray array) { ListBoolean listBoolean = array.getData(); @@ -342,10 +401,8 @@ private void updateTable(VType liveData) { private void parseAndUpdateThreshold(String value) { thresholdSpinner.getEditor().getStyleClass().remove("input-error"); thresholdSpinner.setTooltip(null); - - double parsedNumber; try { - parsedNumber = Double.parseDouble(value.trim()); + double parsedNumber = Double.parseDouble(value.trim()); updateThreshold(parsedNumber); } catch (Exception e) { thresholdSpinner.getEditor().getStyleClass().add("input-error"); @@ -354,8 +411,8 @@ private void parseAndUpdateThreshold(String value) { } /** - * Computes thresholds on scalar data types. The threshold is used to indicate that a delta value within threshold - * should not decorate the delta column, i.e. consider saved and live values equal. + * 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 */ diff --git a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java index 4562d0fae8..77e31a632a 100644 --- a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java +++ b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java @@ -30,7 +30,7 @@ public void start(Stage primaryStage) { Alarm.none(), Time.now(), Display.none()); ComparisonDialog comparisonDialog = - new ComparisonDialog(vDoubleArray, "loc://x(1, 1.9, 4, 8)"); + new ComparisonDialog(vDoubleArray, "loc://x(3, 2, 1)"); comparisonDialog.show(); } } diff --git a/core/vtype/src/main/java/org/phoebus/core/vtypes/VTypeHelper.java b/core/vtype/src/main/java/org/phoebus/core/vtypes/VTypeHelper.java index 843db11ae8..6011f7bc21 100644 --- a/core/vtype/src/main/java/org/phoebus/core/vtypes/VTypeHelper.java +++ b/core/vtype/src/main/java/org/phoebus/core/vtypes/VTypeHelper.java @@ -278,6 +278,8 @@ public static int getArraySize(final VType value) { sizes = ((VEnumArray) value).getSizes(); } else if (value instanceof VStringArray) { sizes = ((VStringArray) value).getSizes(); + } else if (value instanceof VBooleanArray) { + sizes = ((VBooleanArray) value).getSizes(); } else { return 0; } diff --git a/core/vtype/src/test/java/org/phoebus/core/vtypes/VTypeHelperTest.java b/core/vtype/src/test/java/org/phoebus/core/vtypes/VTypeHelperTest.java index 6c7f6d1393..360034e50e 100644 --- a/core/vtype/src/test/java/org/phoebus/core/vtypes/VTypeHelperTest.java +++ b/core/vtype/src/test/java/org/phoebus/core/vtypes/VTypeHelperTest.java @@ -192,6 +192,10 @@ public void testGetArraySize() { VStringArray stringArray = VStringArray.of(Arrays.asList("a", "b"), alarm, time); assertEquals(2, VTypeHelper.getArraySize(stringArray)); + + VBooleanArray booleanArray = + VBooleanArray.of(ArrayBoolean.of(true, false, true), alarm, time); + assertEquals(3, VTypeHelper.getArraySize(booleanArray)); } @Test From ba95624e375a3f855963c28d2bdfc9f4f1463359 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Mon, 8 Dec 2025 13:02:03 +0100 Subject: [PATCH 40/83] Adding documentation --- .../app/doc/images/compare-arrays.png | Bin 0 -> 43982 bytes .../app/doc/images/snapshot-view-with-delta.png | Bin 0 -> 44638 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/save-and-restore/app/doc/images/compare-arrays.png create mode 100644 app/save-and-restore/app/doc/images/snapshot-view-with-delta.png 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 0000000000000000000000000000000000000000..edb51b85fc18bd763a42b1531f0cc17b305e56ff GIT binary patch literal 43982 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

ykr+v>S)08QC}DM9~Dj1f-Bh%0+>5 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 yR_5V5bpAO>xKBr2MjS Date: Tue, 9 Dec 2025 14:10:29 +0100 Subject: [PATCH 41/83] Return infinite delta if arrays are of different length --- .../applications/saveandrestore/ui/VTypePair.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 d653876b52..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 @@ -20,7 +20,9 @@ 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; @@ -59,11 +61,19 @@ public VTypePair(VType base, VType value, Optional> threshold) { * 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 == null || value == null){ - return 0.0; + 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() - From fc547ad5f587199d4e1a6884f4227a559fb55121 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Tue, 9 Dec 2025 14:46:52 +0100 Subject: [PATCH 42/83] Updated ordering and documentation --- .../images/compare-arrays-infinite-delta.png | Bin 0 -> 17946 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/save-and-restore/app/doc/images/compare-arrays-infinite-delta.png 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 0000000000000000000000000000000000000000..cc6ccf97fd3d2cb82b45620f3d1025e5414822d4 GIT binary patch literal 17946 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 literal 0 HcmV?d00001 From d355c647d7e63560b6d92fe82356ed5fe5b88bb3 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Tue, 9 Dec 2025 14:52:51 +0100 Subject: [PATCH 43/83] Updated ordering and documentation --- app/save-and-restore/app/doc/index.rst | 23 +++++++++++++++++++ .../compare/ComparisonDialogDemo.java | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/save-and-restore/app/doc/index.rst b/app/save-and-restore/app/doc/index.rst index f826a173fc..bdb3de6d1f 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 -------------------- diff --git a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java index 77e31a632a..cb945566a0 100644 --- a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java +++ b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java @@ -30,7 +30,7 @@ public void start(Stage primaryStage) { Alarm.none(), Time.now(), Display.none()); ComparisonDialog comparisonDialog = - new ComparisonDialog(vDoubleArray, "loc://x(3, 2, 1)"); + new ComparisonDialog(vDoubleArray, "loc://x(3, 2, 1, 1)"); comparisonDialog.show(); } } From 24f8a08bb1a1d614296719f314105f66a604717a Mon Sep 17 00:00:00 2001 From: georgweiss Date: Wed, 10 Dec 2025 15:40:56 +0100 Subject: [PATCH 44/83] Add dimensions and non-equal count to comparison dialog --- .../app/doc/images/compare-arrays.png | Bin 43982 -> 50178 bytes .../TableComparisonViewController.java | 203 ++++++++---------- .../saveandrestore/messages.properties | 3 + .../snapshot/compare/TableComparisonView.fxml | 81 +++++-- .../compare/ComparisonDialogDemo.java | 4 +- 5 files changed, 159 insertions(+), 132 deletions(-) diff --git a/app/save-and-restore/app/doc/images/compare-arrays.png b/app/save-and-restore/app/doc/images/compare-arrays.png index edb51b85fc18bd763a42b1531f0cc17b305e56ff..f5a80fb9ec78b3bf562d4a110066050fd3204210 100644 GIT binary patch literal 50178 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 literal 43982 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

ykr+v>S)08QC}DM9~Dj1f-Bh%0+>5 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 parseAndUpdateThreshold(n)); + + dimensionLive.textProperty().bind(dimensionLiveProperty); + dimensionStored.textProperty().bind(dimensionStoredProperty); + nonEqualCount.textProperty().bind(nonEqualCountProperty); } /** @@ -137,74 +159,60 @@ public void loadDataAndConnect(VType data, String pvName) { pvNameProperty.set(pvName); int arraySize = VTypeHelper.getArraySize(data); - if (data instanceof VNumberArray) { - for (int index = 0; index < arraySize; index++) { - List columnEntries = new ArrayList<>(); + 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 columnEntry = new ColumnEntry(VDouble.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); + 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 columnEntry = new ColumnEntry(VFloat.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); + 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 columnEntry = new ColumnEntry(VInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); + 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 columnEntry = new ColumnEntry(VUInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); + 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 columnEntry = new ColumnEntry(VLong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); + 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 columnEntry = new ColumnEntry(VULong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); + 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 columnEntry = new ColumnEntry(VShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); + 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 columnEntry = new ColumnEntry(VUShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); + 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 columnEntry = new ColumnEntry(VByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); + 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 columnEntry = new ColumnEntry(VUByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); + columnEntry = new ColumnEntry(VUByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); } } - } else if (data instanceof VBooleanArray array) { - ListBoolean listBoolean = array.getData(); - for (int index = 0; index < listBoolean.size(); index++) { - List columnEntries = new ArrayList<>(); + else if (data instanceof VBooleanArray array) { + ListBoolean listBoolean = array.getData(); boolean value = listBoolean.getBoolean(index); - ColumnEntry columnEntry = new ColumnEntry(VBoolean.of(value, array.getAlarm(), array.getTime())); - addRow(index, columnEntries, columnEntry); + 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())); } - } else if (data instanceof VEnumArray array) { - List enumValues = array.getData(); - for (int index = 0; index < enumValues.size(); index++) { - List columnEntries = new ArrayList<>(); - ColumnEntry columnEntry = new ColumnEntry(VString.of(enumValues.get(index), array.getAlarm(), array.getTime())); - addRow(index, columnEntries, columnEntry); - } - } else if (data instanceof VStringArray array) { - List stringValues = array.getData(); - for (int index = 0; index < stringValues.size(); index++) { - List columnEntries = new ArrayList<>(); - ColumnEntry 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(); } @@ -299,103 +307,60 @@ private void updateTable(VType liveData) { // Live data may have more elements than stored data if (liveDataArraySize > comparisonTable.getItems().size()) { List columnEntries = new ArrayList<>(); - if (liveData instanceof VNumberArray) { - if (liveData instanceof VDoubleArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + 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 columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VDouble.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); - } - } else if (liveData instanceof VFloatArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + } else if (liveData instanceof VFloatArray array) { float value = array.getData().getFloat(index); - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VFloat.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); - } - } else if (liveData instanceof VIntArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + } else if (liveData instanceof VIntArray array) { int value = array.getData().getInt(index); - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); - } - } else if (liveData instanceof VUIntArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + } else if (liveData instanceof VUIntArray array) { int value = array.getData().getInt(index); - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VUInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); - } - } else if (liveData instanceof VLongArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + } else if (liveData instanceof VLongArray array) { long value = array.getData().getLong(index); - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VLong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); - } - } else if (liveData instanceof VULongArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + } else if (liveData instanceof VULongArray array) { long value = array.getData().getLong(index); - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VULong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); - } - } else if (liveData instanceof VShortArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + } else if (liveData instanceof VShortArray array) { short value = array.getData().getShort(index); - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); - } - } else if (liveData instanceof VUShortArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + } else if (liveData instanceof VUShortArray array) { short value = array.getData().getShort(index); - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VUShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); - } - } else if (liveData instanceof VByteArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + } else if (liveData instanceof VByteArray array) { byte value = array.getData().getByte(index); - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); - } - } else if (liveData instanceof VUByteArray array) { - for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + } else if (liveData instanceof VUByteArray array) { byte value = array.getData().getByte(index); - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); columnEntry.setLiveVal(VUByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); - addRow(index, columnEntries, columnEntry); } } - } else if (liveData instanceof VBooleanArray array) { - ListBoolean listBoolean = array.getData(); - for (int i = 0; i < listBoolean.size(); i++) { - boolean value = listBoolean.getBoolean(i); - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + else if (liveData instanceof VBooleanArray array) { + ListBoolean listBoolean = array.getData(); + boolean value = listBoolean.getBoolean(index); columnEntry.setLiveVal(VBoolean.of(value, array.getAlarm(), array.getTime())); - addRow(i, columnEntries, columnEntry); - } - } else if (liveData instanceof VEnumArray array) { - List enumValues = array.getData(); - for (int i = 0; i < enumValues.size(); i++) { - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); - columnEntry.setLiveVal(VString.of(enumValues.get(i), array.getAlarm(), array.getTime())); - addRow(i, columnEntries, columnEntry); - } - } else if (liveData instanceof VStringArray array) { - List stringValues = array.getData(); - for (int i = 0; i < stringValues.size(); i++) { - ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); - columnEntry.setLiveVal(VString.of(stringValues.get(i), array.getAlarm(), array.getTime())); - addRow(i, columnEntries, columnEntry); + } 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) { @@ -418,8 +383,24 @@ private void parseAndUpdateThreshold(String value) { */ 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 7070d4f03f..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 @@ -74,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 @@ -145,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 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 index 19099a9ca9..b0d0e9cb9b 100644 --- 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 @@ -1,23 +1,66 @@ - - - - + + + + + + + + + + - - + - - - - + + +

+ + + +
+ +
@@ -28,9 +71,9 @@ - - - + + + diff --git a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java index cb945566a0..2781bd3695 100644 --- a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java +++ b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java @@ -26,11 +26,11 @@ public static void main(String[] args) { public void start(Stage primaryStage) { VDoubleArray vDoubleArray = - VDoubleArray.of(ArrayDouble.of(1, 2, 3), + VDoubleArray.of(ArrayDouble.of(1, 2, 3, 4), Alarm.none(), Time.now(), Display.none()); ComparisonDialog comparisonDialog = - new ComparisonDialog(vDoubleArray, "loc://x(3, 2, 1, 1)"); + new ComparisonDialog(vDoubleArray, "loc://x(3, 2, 1)"); comparisonDialog.show(); } } From d2fba8be032096a14274fb51f9eefd3e9672afa2 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Wed, 10 Dec 2025 15:45:39 +0100 Subject: [PATCH 45/83] Do not launch comparison dialog if live PV is not connected --- .../saveandrestore/ui/snapshot/VDeltaCellEditor.java | 2 ++ 1 file changed, 2 insertions(+) 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 e072c002d0..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 @@ -96,6 +96,8 @@ public void updateItem(T item, boolean empty) { 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(); From f6e8ee5f9b2252d51fb39ae5df83e679def8ef28 Mon Sep 17 00:00:00 2001 From: shroffk Date: Wed, 10 Dec 2025 14:28:33 -0500 Subject: [PATCH 46/83] First example of alarm configuration dialogs with the alarm tree --- .../alarm/ui/tree/AlarmTreeConfigDialog.java | 119 +++ .../alarm/ui/tree/AlarmTreeConfigView.java | 710 ++++++++++++++++++ .../alarm/ui/tree/MoveTreeItemAction.java | 27 +- 3 files changed, 846 insertions(+), 10 deletions(-) create mode 100644 app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigDialog.java create mode 100644 app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigView.java 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..50f5667a9d --- /dev/null +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigDialog.java @@ -0,0 +1,119 @@ +package org.phoebus.applications.alarm.ui.tree; + +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); + + // Add listener to update path when tree selection changes + // Access the tree view through reflection or by wrapping it + if (configView.getCenter() instanceof TreeView) + { + @SuppressWarnings("unchecked") + TreeView> treeView = (TreeView>) configView.getCenter(); + treeView.getSelectionModel().selectedItemProperty().addListener((obs, oldVal, newVal) -> + { + if (newVal != null && newVal.getValue() != null) + { + String selectedPath = newVal.getValue().getPathName(); + if (selectedPath != null && !selectedPath.isEmpty()) + { + pathInput.setText(selectedPath); + } + } + }); + } + + // 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..8ee945a9e5 --- /dev/null +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/AlarmTreeConfigView.java @@ -0,0 +1,710 @@ +/******************************************************************************* + * 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.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.ContextMenu; +import javafx.scene.control.Label; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SelectionMode; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.ToolBar; +import javafx.scene.control.Tooltip; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; +import javafx.scene.image.ImageView; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.Dragboard; +import javafx.scene.input.TransferMode; +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.client.AlarmClientNode; +import org.phoebus.applications.alarm.model.AlarmTreeItem; +import org.phoebus.applications.alarm.model.BasicState; +import org.phoebus.applications.alarm.ui.AlarmContextMenuHelper; +import org.phoebus.applications.alarm.ui.AlarmUI; +import org.phoebus.framework.selection.Selection; +import org.phoebus.framework.selection.SelectionService; +import org.phoebus.ui.application.ContextMenuService; +import org.phoebus.ui.application.SaveSnapshotAction; +import org.phoebus.ui.dialog.DialogHelper; +import org.phoebus.ui.docking.DockPane; +import org.phoebus.ui.javafx.ImageCache; +import org.phoebus.ui.javafx.PrintAction; +import org.phoebus.ui.javafx.Screenshot; +import org.phoebus.ui.javafx.ToolbarHelper; +import org.phoebus.ui.javafx.UpdateThrottle; +import org.phoebus.ui.selection.AppSelection; +import org.phoebus.ui.spi.ContextMenuEntry; +import org.phoebus.util.text.CompareNatural; + +import java.util.ArrayList; +import java.util.Arrays; +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 java.util.stream.Collectors; + +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(); + }); + updateStats(); + + // 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(); + }); + updateStats(); + + // 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(); + updateStats(); + } + + /** 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); + } + // Restore selection + tree_config_view.getSelectionModel().clearSelection(); + updatedSelectedItems.forEach(item -> tree_config_view.getSelectionModel().select(item)); + } + + /** Context menu, details depend on selected items */ + private void createContextMenu() + { + final ContextMenu menu = new ContextMenu(); + + tree_config_view.setOnContextMenuRequested(event -> + { + final ObservableList menu_items = menu.getItems(); + menu_items.clear(); + + final List> selection = tree_config_view.getSelectionModel().getSelectedItems().stream().map(TreeItem::getValue).collect(Collectors.toList()); + + // Add guidance etc. + new AlarmContextMenuHelper().addSupportedEntries(tree_config_view, model, menu, selection); + if (menu_items.size() > 0) + menu_items.add(new SeparatorMenuItem()); + + if (AlarmUI.mayConfigure(model)) + { + if (selection.size() <= 0) + // Add first item to empty config + menu_items.add(new AddComponentAction(tree_config_view, model, model.getRoot())); + else if (selection.size() == 1) + { + final AlarmTreeItem item = selection.get(0); + menu_items.add(new ConfigureComponentAction(tree_config_view, model, item)); + menu_items.add(new SeparatorMenuItem()); + + if (item instanceof AlarmClientNode) + menu_items.add(new AddComponentAction(tree_config_view, model, item)); + + menu_items.add(new RenameTreeItemAction(tree_config_view, model, item)); + + if (item instanceof AlarmClientLeaf) + menu_items.add(new DuplicatePVAction(tree_config_view, model, (AlarmClientLeaf) item)); + + menu_items.add(new MoveTreeItemAction(tree_config_view, model, item)); + } + if (selection.size() >= 1) + { + menu_items.add(new EnableComponentAction(tree_config_view, model, selection)); + menu_items.add(new DisableComponentAction(tree_config_view, model, selection)); + menu_items.add(new RemoveComponentAction(tree_config_view, model, selection)); + } + } + + menu_items.add(new SeparatorMenuItem()); + menu_items.add(new PrintAction(tree_config_view)); + menu_items.add(new SaveSnapshotAction(DockPane.getActiveDockPane())); + + // Add context menu actions based on the selection (i.e. email, logbook, etc...) + final Selection originalSelection = SelectionService.getInstance().getSelection(); + final List newSelection = Arrays.asList(AppSelection.of(tree_config_view, "Alarm Screenshot", "See alarm tree screenshot", () -> Screenshot.imageFromNode(tree_config_view))); + SelectionService.getInstance().setSelection("AlarmTree", newSelection); + List supported = ContextMenuService.getInstance().listSupportedContextMenuEntries(); + supported.stream().forEach(action -> { + MenuItem menuItem = new MenuItem(action.getName(), new ImageView(action.getIcon())); + menuItem.setOnAction((e) -> { + try + { + SelectionService.getInstance().setSelection("AlarmTree", newSelection); + action.call(tree_config_view, SelectionService.getInstance().getSelection()); + } catch (Exception ex) + { + logger.log(Level.WARNING, "Failed to execute " + action.getName() + " from AlarmTree.", ex); + } + }); + menu_items.add(menuItem); + }); + SelectionService.getInstance().setSelection("AlarmTree", originalSelection); + + menu.show(tree_config_view.getScene().getWindow(), event.getScreenX(), event.getScreenY()); + }); + } + + /** Double-click on item opens configuration dialog */ + private void addClickSupport() + { + tree_config_view.setOnMouseClicked(event -> + { + if (!AlarmUI.mayConfigure(model) || + event.getClickCount() != 2 || + tree_config_view.getSelectionModel().getSelectedItems().size() != 1) + return; + + final AlarmTreeItem item = tree_config_view.getSelectionModel().getSelectedItems().get(0).getValue(); + final ItemConfigDialog dialog = new ItemConfigDialog(model, item); + DialogHelper.positionDialog(dialog, tree_config_view, -150, -300); + // Show dialog, not waiting for it to close with OK or Cancel + dialog.show(); + }); + } + + /** For leaf nodes, drag PV name */ + private void addDragSupport() + { + tree_config_view.setOnDragDetected(event -> + { + final ObservableList>> items = tree_config_view.getSelectionModel().getSelectedItems(); + if (items.size() != 1) + return; + final AlarmTreeItem item = items.get(0).getValue(); + if (! (item instanceof AlarmClientLeaf)) + return; + final Dragboard db = tree_config_view.startDragAndDrop(TransferMode.COPY); + final ClipboardContent content = new ClipboardContent(); + content.putString(item.getName()); + db.setContent(content); + event.consume(); + }); + } + +// private long next_stats = 0; +// private final AtomicInteger update_count = new AtomicInteger(); +// private volatile double updates_per_sec = 0.0; + + private void updateStats() + { +// final long time = System.currentTimeMillis(); +// if (time > next_stats) +// { +// final int updates = update_count.getAndSet(0); +// updates_per_sec = updates_per_sec * 0.9 + updates * 0.1; +// next_stats = time + 1000; +// System.out.format("%.2f updates/sec\n", updates_per_sec); +// } +// else +// update_count.incrementAndGet(); + } + + 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); + } + } +} 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..8d0ee3caf4 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 @@ -39,25 +39,32 @@ 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); } // 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. From 653a4b3a8b191517a213d48bfc389c3663856bb3 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Thu, 11 Dec 2025 09:58:12 +0100 Subject: [PATCH 47/83] Send to log/email from alarm ui bug fix (acknowledged list same as active) --- .../org/phoebus/applications/alarm/ui/table/AlarmTableUI.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); From 6350631af5d82259d12c054c1839fbcccb361778 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Thu, 11 Dec 2025 13:16:48 +0100 Subject: [PATCH 48/83] Fix line break for log entries created from Alarm Table UI --- .../applications/alarm/ui/table/AlarmInfoRow.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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(); } From a190d292ce85b7f96581bf8f197afe2e944af490 Mon Sep 17 00:00:00 2001 From: Abraham Wolk Date: Fri, 12 Dec 2025 09:34:56 +0100 Subject: [PATCH 49/83] CSSTUDIO-3605 Always use the type "long" when converting to hexadecimal notation. --- .../java/org/phoebus/ui/vtype/FormatOptionHandler.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java b/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java index 2cb6097538..ddd8fd0585 100644 --- a/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java +++ b/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java @@ -219,10 +219,9 @@ private static String formatNumber(final Number value, final Display display, if (option == FormatOption.HEX) { final StringBuilder buf = new StringBuilder(); - if (precision <= 8) - buf.append(Integer.toHexString(value.intValue()).toUpperCase()); - else - buf.append(Long.toHexString(value.longValue()).toUpperCase()); + long longValue = value.longValue(); + String hexString = Long.toHexString(longValue); + buf.append(hexString.toUpperCase()); for (int i=buf.length(); i Date: Fri, 12 Dec 2025 10:49:31 -0500 Subject: [PATCH 50/83] RDB archive: Fix Oracle time zone problem. The sample.smpl_time is handled differently for MySQL, Postgres, Oracle. In MySQL, it's stored as UTC. The `Timestamp` passed to `PreparedStatement.setTimestamp(Timestamp)` is converted to UTC and stored as UTC. On retrieval, `ResultSet.getTimestamp` converts back to the local time zone with appropriate GMT offset. In Postgres, we need to use a `TIMESTAMPTZ` datatype that stores the stamp with timezone info. Both MySQL and Postgres handle the fall transition from daylight savings time back to standard time just fine. In Oracle, even if we use `TIMESTAMP WITH TIMEZONE` for smpl_time, Oracle JDBC will second-guess the Timestamp passed to `setTimestamp(Timestamp)` and change the time offset. During the DST change in the fall, time stamps will be written with the wrong GMT offset. The workaround is to use `setObject(OffsetDateTime)` because OffsetDateTime is passed through and written as received. --- .../archive-engine/dbd/postgres_schema.txt | 2 +- .../archive/writer/rdb/RDBArchiveWriter.java | 13 +++--- .../archive/writer/rdb/TimestampHelper.java | 45 ++++++++++--------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/services/archive-engine/dbd/postgres_schema.txt b/services/archive-engine/dbd/postgres_schema.txt index 1649820a49..4947bd3b6c 100644 --- a/services/archive-engine/dbd/postgres_schema.txt +++ b/services/archive-engine/dbd/postgres_schema.txt @@ -199,7 +199,7 @@ DROP TABLE IF EXISTS sample; CREATE TABLE sample ( channel_id BIGINT NOT NULL, - smpl_time TIMESTAMP NOT NULL, + smpl_time TIMESTAMPTZ NOT NULL, nanosecs BIGINT NOT NULL, severity_id BIGINT NOT NULL, status_id BIGINT NOT NULL, diff --git a/services/archive-engine/src/main/java/org/csstudio/archive/writer/rdb/RDBArchiveWriter.java b/services/archive-engine/src/main/java/org/csstudio/archive/writer/rdb/RDBArchiveWriter.java index 38c54216ee..516a3f47fa 100644 --- a/services/archive-engine/src/main/java/org/csstudio/archive/writer/rdb/RDBArchiveWriter.java +++ b/services/archive-engine/src/main/java/org/csstudio/archive/writer/rdb/RDBArchiveWriter.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2011-2024 Oak Ridge National Laboratory. + * Copyright (c) 2011-2025 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 @@ -51,7 +51,6 @@ * @author Lana Abadie - PostgreSQL for original RDBArchive code. Disable autocommit as needed. * @author Laurent Philippe (Use read-only connection when possible for MySQL load balancing) */ -@SuppressWarnings("nls") public class RDBArchiveWriter implements ArchiveWriter { /** Status string for Double.NaN samples */ @@ -430,9 +429,8 @@ private void oldBatchDoubleSamples(final RDBWriteChannel channel, final int N = additional.size(); for (int i = 1; i < N; i++) { - if (dialect == Dialect.Oracle){ - insert_array_sample.setTimestamp(2, stamp); - } + if (dialect == Dialect.Oracle) + insert_array_sample.setObject(2, TimestampHelper.toOffsetDateTime(stamp)); else { // Truncate the time stamp @@ -498,9 +496,8 @@ private void completeAndBatchInsert( final Timestamp stamp, final int severity, final Status status) throws Exception { - if (dialect == Dialect.Oracle){ - insert_xx.setTimestamp(2, stamp); - } + if (dialect == Dialect.Oracle) + insert_xx.setObject(2, TimestampHelper.toOffsetDateTime(stamp)); else { // Truncate the time stamp diff --git a/services/archive-engine/src/main/java/org/csstudio/archive/writer/rdb/TimestampHelper.java b/services/archive-engine/src/main/java/org/csstudio/archive/writer/rdb/TimestampHelper.java index 7c0b865ca9..909d74afbb 100644 --- a/services/archive-engine/src/main/java/org/csstudio/archive/writer/rdb/TimestampHelper.java +++ b/services/archive-engine/src/main/java/org/csstudio/archive/writer/rdb/TimestampHelper.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2012-2018 Oak Ridge National Laboratory. + * Copyright (c) 2012-2025 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 @@ -9,9 +9,9 @@ import java.time.Duration; import java.time.Instant; +import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; -import java.util.Calendar; import java.util.concurrent.TimeUnit; import org.phoebus.util.time.TimestampFormats; @@ -19,7 +19,6 @@ /** Time stamp gymnastics * @author Kay Kasemir */ -@SuppressWarnings("nls") public class TimestampHelper { /** @param timestamp {@link Instant}, may be null @@ -32,17 +31,6 @@ public static String format(final Instant timestamp) return TimestampFormats.FULL_FORMAT.format(timestamp); } - // May look like just time_format.format(Instant) works, - // but results in runtime error " java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: YearOfEra" - // because time for printing needs to be in local time -// public static void main(String[] args) -// { -// final Instant now = Instant.now(); -// System.out.println(format(now)); -// System.out.println(time_format.format(now)); -// } - - /** @param timestamp EPICS Timestamp * @return SQL Timestamp */ @@ -77,14 +65,29 @@ public static Instant fromMillisecs(final long millisecs) } return Instant.ofEpochSecond(seconds, nanoseconds); } - - /** @param calendar Calendar - * @return EPICS Timestamp + + /** Zone ID is something like "America/New_York". + * Within that zone, time might change between + * EDT (daylight saving) and EST (standard), + * but the Zone ID remains, so we can keep it final. */ - public static Instant fromCalendar(final Calendar calendar) - { - return fromMillisecs(calendar.getTimeInMillis()); - } + final private static ZoneId zone = ZoneId.systemDefault(); + + /** Turn SQL {@link java.sql.Timestamp} into {@link OffsetDateTime} + * + * Oracle JDBC PreparedStatement.setTimestamp(int, Timestamp) + * will change the zone info in unexpected ways. + * Using PreparedStatement.setObject(int, OffsetDateTime) + * is the suggested workaround, so this morphs a Timestamp + * into OffsetDateTime + * + * @param sql_time SQL {@link java.sql.Timestamp} + * @return {@link OffsetDateTime} + */ + public static OffsetDateTime toOffsetDateTime(final java.sql.Timestamp sql_time) + { + return OffsetDateTime.ofInstant(sql_time.toInstant(), zone); + } /** Round time to next multiple of given duration * @param time Original time stamp From fb37c8cd6cd59f41c21797784c1fb9c2cd7b5569 Mon Sep 17 00:00:00 2001 From: Abraham Wolk Date: Mon, 15 Dec 2025 09:41:44 +0100 Subject: [PATCH 51/83] CSSTUDIO-3605 Bugfix: Avoid sign extension when converting numbers to the type Long. --- .../phoebus/ui/vtype/FormatOptionHandler.java | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java b/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java index ddd8fd0585..6f654f6e8b 100644 --- a/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java +++ b/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java @@ -18,6 +18,10 @@ import java.util.logging.Logger; import org.epics.util.array.ListNumber; +import org.epics.util.number.UByte; +import org.epics.util.number.UInteger; +import org.epics.util.number.ULong; +import org.epics.util.number.UShort; import org.epics.vtype.Display; import org.epics.vtype.DisplayProvider; import org.epics.vtype.VBoolean; @@ -219,7 +223,30 @@ private static String formatNumber(final Number value, final Display display, if (option == FormatOption.HEX) { final StringBuilder buf = new StringBuilder(); - long longValue = value.longValue(); + long longValue; + if (value instanceof Byte valueByte) { + longValue = Byte.toUnsignedLong(valueByte); + } else if (value instanceof Short valueShort) { + longValue = Short.toUnsignedLong(valueShort); + } else if (value instanceof Integer valueInt) { + longValue = Integer.toUnsignedLong(valueInt); + } else if (value instanceof Long valueLong) { + longValue = valueLong; + } else if (value instanceof Float valueFloat) { + longValue = valueFloat.longValue(); + } else if (value instanceof Double valueDouble) { + longValue = valueDouble.longValue(); + } else if (value instanceof UByte valueUByte) { + longValue = valueUByte.longValue(); + } else if (value instanceof UShort valueUShort) { + longValue = valueUShort.longValue(); + } else if (value instanceof UInteger valueUInteger) { + longValue = valueUInteger.longValue(); + } else if (value instanceof ULong valueULong) { + longValue = valueULong.longValue(); + } else { + longValue = value.longValue(); + } String hexString = Long.toHexString(longValue); buf.append(hexString.toUpperCase()); for (int i=buf.length(); i Date: Mon, 15 Dec 2025 09:42:34 +0100 Subject: [PATCH 52/83] CSSTUDIO-3605 Add test cases. --- .../ui/vtype/FormatOptionHandlerTest.java | 260 +++++++++++++++++- 1 file changed, 247 insertions(+), 13 deletions(-) diff --git a/core/ui/src/test/java/org/phoebus/ui/vtype/FormatOptionHandlerTest.java b/core/ui/src/test/java/org/phoebus/ui/vtype/FormatOptionHandlerTest.java index d4a0625650..3e4072e915 100644 --- a/core/ui/src/test/java/org/phoebus/ui/vtype/FormatOptionHandlerTest.java +++ b/core/ui/src/test/java/org/phoebus/ui/vtype/FormatOptionHandlerTest.java @@ -15,14 +15,22 @@ import org.epics.vtype.Display; import org.epics.vtype.EnumDisplay; import org.epics.vtype.Time; +import org.epics.vtype.VByte; import org.epics.vtype.VDouble; import org.epics.vtype.VEnum; +import org.epics.vtype.VFloat; +import org.epics.vtype.VInt; import org.epics.vtype.VIntArray; import org.epics.vtype.VLong; import org.epics.vtype.VNumberArray; +import org.epics.vtype.VShort; import org.epics.vtype.VString; import org.epics.vtype.VStringArray; import org.epics.vtype.VType; +import org.epics.vtype.VUByte; +import org.epics.vtype.VUInt; +import org.epics.vtype.VULong; +import org.epics.vtype.VUShort; import org.junit.jupiter.api.Test; import java.text.DecimalFormat; @@ -167,19 +175,245 @@ public void testEngineering() @Test public void testHexFormat() { - VType number = VDouble.of(65535.0, Alarm.none(), Time.now(), display); - - String text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); - System.out.println(text); - assertThat(text, equalTo("0xFFFF V")); - - text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); - System.out.println(text); - assertThat(text, equalTo("0x0000FFFF V")); - - text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); - System.out.println(text); - assertThat(text, equalTo("0x000000000000FFFF V")); + { + VType number = VDouble.of(65535.0, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0x0000FFFF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0x000000000000FFFF V")); + } + + { + // Test with VDouble representing 0xFFFFFFFF + VType number = VDouble.of(4294967295.0, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFFF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFFF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0x00000000FFFFFFFF V")); + } + + { + // Test with VFloat representing 0xFFFF + VType number = VFloat.of(65535.0, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0x0000FFFF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0x000000000000FFFF V")); + } + + { + // Test with VInt representing 0x00 + VType number = VInt.of(0x00, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0x0000 V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0x00000000 V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0x0000000000000000 V")); + } + + { + // Test with VByte representing 0xFF + VType number = VByte.of(0xFF, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 2, true); + System.out.println(text); + assertThat(text, equalTo("0xFF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0x00FF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0x000000FF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0x00000000000000FF V")); + } + + { + // Test with VByte representing 0xFF + VType number = VUByte.of(0xFF, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 2, true); + System.out.println(text); + assertThat(text, equalTo("0xFF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0x00FF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0x000000FF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0x00000000000000FF V")); + } + + { + // Test with VShort representing 0xFFAA + VType number = VShort.of(0xFFAA, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 2, true); + System.out.println(text); + assertThat(text, equalTo("0xFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0xFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0x0000FFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0x000000000000FFAA V")); + } + + { + // Test with VUShort representing 0xFFAA + VType number = VUShort.of(0xFFAA, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 2, true); + System.out.println(text); + assertThat(text, equalTo("0xFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0xFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0x0000FFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0x000000000000FFAA V")); + } + + { + // Test with VInt representing 0xFF + VType number = VInt.of(0xFF, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 2, true); + System.out.println(text); + assertThat(text, equalTo("0xFF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0x00FF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0x000000FF V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0x00000000000000FF V")); + } + + { + // Test with VInt representing 0xFFFFFFAA + VType number = VInt.of(0xFFFFFFAA, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0x00000000FFFFFFAA V")); + } + + { + // Test with VUInt representing 0xFFFFFFAA + VType number = VUInt.of(0xFFFFFFAA, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0x00000000FFFFFFAA V")); + } + + { + // Test with VLong representing 0xFFFFFFAA + VType number = VLong.of(0xFFFFFFFFFFFFFFAAL, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFFFFFFFFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFFFFFFFFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFFFFFFFFFAA V")); + } + + { + // Test with VULong representing 0xFFFFFFAA + VType number = VULong.of(0xFFFFFFFFFFFFFFAAL, Alarm.none(), Time.now(), display); + + String text = FormatOptionHandler.format(number, FormatOption.HEX, 4, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFFFFFFFFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 8, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFFFFFFFFFAA V")); + + text = FormatOptionHandler.format(number, FormatOption.HEX, 16, true); + System.out.println(text); + assertThat(text, equalTo("0xFFFFFFFFFFFFFFAA V")); + } } @Test From 717fc53fadc9202a16157e173d887ea62604e1ce Mon Sep 17 00:00:00 2001 From: lcaouen Date: Mon, 15 Dec 2025 10:37:11 +0100 Subject: [PATCH 53/83] check if property files are in classes folders and exclude keys from the check --- .../preferences/PropertyPreferenceWriter.java | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java b/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java index 310df03199..77fb372a0f 100644 --- a/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java +++ b/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java @@ -16,6 +16,7 @@ import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Enumeration; @@ -28,6 +29,7 @@ import java.util.prefs.Preferences; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Stream; /** Write preferences in property file format * @author Kay Kasemir @@ -58,6 +60,7 @@ public static void save(final OutputStream stream) throws Exception out.append("# the.package.name/key=value
\n"); out.append("

# key=value in red are incorrect properties

\n"); listSettings(getAllPropertyKeys(), out, Preferences.userRoot()); + out.append("
\n"); out.append("# End.
\n"); out.flush(); } @@ -76,7 +79,15 @@ private static void formatSetting(Map allKeysWithPackages, final final String path = node.absolutePath(); String fullKey = path.substring(1).replace('/', '.') + '/' + key; String keyFound = allKeysWithPackages.get(fullKey); - boolean bNotFound = keyFound == null ? true : false; + boolean bNotFound = keyFound == null; + + // ignore keys that can be used but not from preferences.properties + if (key.toLowerCase().contains("external_app") || + key.toLowerCase().contains("password") || + key.toLowerCase().contains("username")) { + bNotFound = false; + } + if (bNotFound) out.append("
"); out.append(escapeHtml(fullKey)) .append('=') @@ -95,9 +106,8 @@ private static Map getAllPropertyKeys() throws Exception if (jars.length == 1) jars = getAllJarFromManifest(jars[0]); for (String jarEntry : jars) { - File file = new File(jarEntry); - if (jarEntry.endsWith(".jar")) { + File file = new File(jarEntry); try (JarFile jarFile = new JarFile(file)) { Enumeration entries = jarFile.entries(); @@ -115,7 +125,24 @@ private static Map getAllPropertyKeys() throws Exception } } catch (IOException e) { System.err.println("Error opening JAR : " + jarEntry); - e.printStackTrace(); + } + } + else if (jarEntry.endsWith("classes")) { + Path startPath = Paths.get(jarEntry); + String filePattern = "preferences.properties"; + + System.out.println(startPath); + try (Stream paths = Files.walk(startPath)) { + paths.filter(path -> path.toString().endsWith(filePattern)) + .forEach(path -> { + try (InputStream inputStream = Files.newInputStream(path)) { + parsePropertiesWithPackage(inputStream, path.getFileName().toString(), allKeysWithPackages); + } catch (IOException e) { + System.err.println("Error opening properties : " + path); + } + }); + } catch (IOException e) { + System.err.println("Error listing files in : " + startPath); } } } @@ -148,7 +175,6 @@ private static String[] getAllJarFromManifest(String jarPath) { } } catch (IOException e) { System.err.println("Error when reading the jar : " + jarPath); - e.printStackTrace(); } return jars; From acb8aad48212c62a81a88e1acc2a8c86b308bc1b Mon Sep 17 00:00:00 2001 From: shroffk Date: Mon, 15 Dec 2025 14:38:56 -0500 Subject: [PATCH 54/83] Cache the move item name to prepare more intuitive destination paths --- .../alarm/ui/tree/AlarmTreeConfigDialog.java | 47 ++++-- .../alarm/ui/tree/AlarmTreeConfigView.java | 153 +++--------------- 2 files changed, 52 insertions(+), 148 deletions(-) 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 index 50f5667a9d..0667f5133d 100644 --- 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 @@ -1,5 +1,6 @@ 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; @@ -48,24 +49,42 @@ public AlarmTreeConfigDialog(AlarmClient alarmClient, String currentPath, String pathInput.setPromptText("Select a path from the tree above or type manually"); pathInput.setEditable(true); - // Add listener to update path when tree selection changes - // Access the tree view through reflection or by wrapping it - if (configView.getCenter() instanceof TreeView) - { - @SuppressWarnings("unchecked") - TreeView> treeView = (TreeView>) configView.getCenter(); - treeView.getSelectionModel().selectedItemProperty().addListener((obs, oldVal, newVal) -> + // 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) { - if (newVal != null && newVal.getValue() != null) + String selectedPath = newVal.getValue().getPathName(); + if (selectedPath != null && !selectedPath.isEmpty()) { - String selectedPath = newVal.getValue().getPathName(); - if (selectedPath != null && !selectedPath.isEmpty()) - { - pathInput.setText(selectedPath); + // 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:"); 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 index 8ee945a9e5..59f0a0008f 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -408,7 +409,6 @@ public void itemAdded(final AlarmTreeItem item) items.add(index, view_item); done.countDown(); }); - updateStats(); // Waiting on the UI thread throttles the model's updates // to a rate that the UI can handle. @@ -450,7 +450,6 @@ public void itemRemoved(final AlarmTreeItem item) view_parent.getChildren().remove(view_item); done.countDown(); }); - updateStats(); // Waiting on the UI thread throttles the model's updates // to a rate that the UI can handle. @@ -503,7 +502,6 @@ public void itemUpdated(final AlarmTreeItem item) items_to_update.add(view_item); } throttle.trigger(); - updateStats(); } /** Called by throttle to perform accumulated updates */ @@ -523,7 +521,7 @@ private void performUpdates() } // Remember selection - final ObservableList>> updatedSelectedItems = + final ObservableList>> updatedSelectedItems = FXCollections.observableArrayList(tree_config_view.getSelectionModel().getSelectedItems()); // How to update alarm tree cells when data changed? @@ -562,139 +560,10 @@ private void performUpdates() path2view.put(value.getPathName(), update); parent.getChildren().set(index, update); } - // Restore selection + tree_config_view.getSelectionModel().clearSelection(); updatedSelectedItems.forEach(item -> tree_config_view.getSelectionModel().select(item)); - } - - /** Context menu, details depend on selected items */ - private void createContextMenu() - { - final ContextMenu menu = new ContextMenu(); - - tree_config_view.setOnContextMenuRequested(event -> - { - final ObservableList menu_items = menu.getItems(); - menu_items.clear(); - - final List> selection = tree_config_view.getSelectionModel().getSelectedItems().stream().map(TreeItem::getValue).collect(Collectors.toList()); - - // Add guidance etc. - new AlarmContextMenuHelper().addSupportedEntries(tree_config_view, model, menu, selection); - if (menu_items.size() > 0) - menu_items.add(new SeparatorMenuItem()); - - if (AlarmUI.mayConfigure(model)) - { - if (selection.size() <= 0) - // Add first item to empty config - menu_items.add(new AddComponentAction(tree_config_view, model, model.getRoot())); - else if (selection.size() == 1) - { - final AlarmTreeItem item = selection.get(0); - menu_items.add(new ConfigureComponentAction(tree_config_view, model, item)); - menu_items.add(new SeparatorMenuItem()); - - if (item instanceof AlarmClientNode) - menu_items.add(new AddComponentAction(tree_config_view, model, item)); - - menu_items.add(new RenameTreeItemAction(tree_config_view, model, item)); - - if (item instanceof AlarmClientLeaf) - menu_items.add(new DuplicatePVAction(tree_config_view, model, (AlarmClientLeaf) item)); - - menu_items.add(new MoveTreeItemAction(tree_config_view, model, item)); - } - if (selection.size() >= 1) - { - menu_items.add(new EnableComponentAction(tree_config_view, model, selection)); - menu_items.add(new DisableComponentAction(tree_config_view, model, selection)); - menu_items.add(new RemoveComponentAction(tree_config_view, model, selection)); - } - } - - menu_items.add(new SeparatorMenuItem()); - menu_items.add(new PrintAction(tree_config_view)); - menu_items.add(new SaveSnapshotAction(DockPane.getActiveDockPane())); - - // Add context menu actions based on the selection (i.e. email, logbook, etc...) - final Selection originalSelection = SelectionService.getInstance().getSelection(); - final List newSelection = Arrays.asList(AppSelection.of(tree_config_view, "Alarm Screenshot", "See alarm tree screenshot", () -> Screenshot.imageFromNode(tree_config_view))); - SelectionService.getInstance().setSelection("AlarmTree", newSelection); - List supported = ContextMenuService.getInstance().listSupportedContextMenuEntries(); - supported.stream().forEach(action -> { - MenuItem menuItem = new MenuItem(action.getName(), new ImageView(action.getIcon())); - menuItem.setOnAction((e) -> { - try - { - SelectionService.getInstance().setSelection("AlarmTree", newSelection); - action.call(tree_config_view, SelectionService.getInstance().getSelection()); - } catch (Exception ex) - { - logger.log(Level.WARNING, "Failed to execute " + action.getName() + " from AlarmTree.", ex); - } - }); - menu_items.add(menuItem); - }); - SelectionService.getInstance().setSelection("AlarmTree", originalSelection); - - menu.show(tree_config_view.getScene().getWindow(), event.getScreenX(), event.getScreenY()); - }); - } - - /** Double-click on item opens configuration dialog */ - private void addClickSupport() - { - tree_config_view.setOnMouseClicked(event -> - { - if (!AlarmUI.mayConfigure(model) || - event.getClickCount() != 2 || - tree_config_view.getSelectionModel().getSelectedItems().size() != 1) - return; - - final AlarmTreeItem item = tree_config_view.getSelectionModel().getSelectedItems().get(0).getValue(); - final ItemConfigDialog dialog = new ItemConfigDialog(model, item); - DialogHelper.positionDialog(dialog, tree_config_view, -150, -300); - // Show dialog, not waiting for it to close with OK or Cancel - dialog.show(); - }); - } - /** For leaf nodes, drag PV name */ - private void addDragSupport() - { - tree_config_view.setOnDragDetected(event -> - { - final ObservableList>> items = tree_config_view.getSelectionModel().getSelectedItems(); - if (items.size() != 1) - return; - final AlarmTreeItem item = items.get(0).getValue(); - if (! (item instanceof AlarmClientLeaf)) - return; - final Dragboard db = tree_config_view.startDragAndDrop(TransferMode.COPY); - final ClipboardContent content = new ClipboardContent(); - content.putString(item.getName()); - db.setContent(content); - event.consume(); - }); - } - -// private long next_stats = 0; -// private final AtomicInteger update_count = new AtomicInteger(); -// private volatile double updates_per_sec = 0.0; - - private void updateStats() - { -// final long time = System.currentTimeMillis(); -// if (time > next_stats) -// { -// final int updates = update_count.getAndSet(0); -// updates_per_sec = updates_per_sec * 0.9 + updates * 0.1; -// next_stats = time + 1000; -// System.out.format("%.2f updates/sec\n", updates_per_sec); -// } -// else -// update_count.incrementAndGet(); } private void dumpTree(TreeItem> item) @@ -707,4 +576,20 @@ private void dumpTree(TreeItem> item) dumpTree(child); } } + + /** + * Allows external classes to attach a selection listener to the tree view. + * @param listener ChangeListener for selected TreeItem + */ + public void addTreeSelectionListener(ChangeListener>> 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>> listener) { + tree_config_view.getSelectionModel().selectedItemProperty().removeListener(listener); + } } From 32893872d1e902fe993031a8dad9fccda9f3ddda Mon Sep 17 00:00:00 2001 From: shroffk Date: Mon, 15 Dec 2025 15:26:15 -0500 Subject: [PATCH 55/83] Cleanup imports for the new alarm tree view --- .../alarm/ui/tree/AlarmTreeConfigView.java | 21 ------------------- 1 file changed, 21 deletions(-) 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 index 59f0a0008f..45b99ff4e5 100644 --- 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 @@ -15,19 +15,12 @@ import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.control.Button; -import javafx.scene.control.ContextMenu; import javafx.scene.control.Label; -import javafx.scene.control.MenuItem; import javafx.scene.control.SelectionMode; -import javafx.scene.control.SeparatorMenuItem; import javafx.scene.control.ToolBar; import javafx.scene.control.Tooltip; import javafx.scene.control.TreeItem; import javafx.scene.control.TreeView; -import javafx.scene.image.ImageView; -import javafx.scene.input.ClipboardContent; -import javafx.scene.input.Dragboard; -import javafx.scene.input.TransferMode; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.BorderPane; @@ -37,28 +30,15 @@ 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.client.AlarmClientNode; import org.phoebus.applications.alarm.model.AlarmTreeItem; import org.phoebus.applications.alarm.model.BasicState; -import org.phoebus.applications.alarm.ui.AlarmContextMenuHelper; import org.phoebus.applications.alarm.ui.AlarmUI; -import org.phoebus.framework.selection.Selection; -import org.phoebus.framework.selection.SelectionService; -import org.phoebus.ui.application.ContextMenuService; -import org.phoebus.ui.application.SaveSnapshotAction; -import org.phoebus.ui.dialog.DialogHelper; -import org.phoebus.ui.docking.DockPane; import org.phoebus.ui.javafx.ImageCache; -import org.phoebus.ui.javafx.PrintAction; -import org.phoebus.ui.javafx.Screenshot; import org.phoebus.ui.javafx.ToolbarHelper; import org.phoebus.ui.javafx.UpdateThrottle; -import org.phoebus.ui.selection.AppSelection; -import org.phoebus.ui.spi.ContextMenuEntry; import org.phoebus.util.text.CompareNatural; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; @@ -69,7 +49,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; -import java.util.stream.Collectors; import static org.phoebus.applications.alarm.AlarmSystem.logger; From d1240883036cae0cda29dff0913313b0bf24997d Mon Sep 17 00:00:00 2001 From: lcaouen Date: Wed, 17 Dec 2025 13:34:43 +0100 Subject: [PATCH 56/83] exclude keys that must not be checked + Remove system.err and system.out --- .../preferences/PropertyPreferenceWriter.java | 71 +++++++++++-------- .../phoebus_ui_preferences.properties | 4 ++ 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java b/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java index 77fb372a0f..cc23f60f36 100644 --- a/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java +++ b/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java @@ -19,16 +19,16 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Enumeration; -import java.util.HashMap; -import java.util.Map; -import java.util.Properties; +import java.util.*; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.Manifest; +import java.util.logging.Level; +import java.util.logging.Logger; import java.util.prefs.Preferences; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import java.util.stream.Stream; /** Write preferences in property file format @@ -37,6 +37,10 @@ @SuppressWarnings("nls") public class PropertyPreferenceWriter { + public static final Logger logger = Logger.getLogger(PropertyPreferenceWriter.class.getName()); + public static Set excludedKeys = new HashSet<>(); + public static Set excludedPackages = new HashSet<>(); + /** Save preferences in property file format * *

Properties have the name "package/setting", @@ -50,16 +54,27 @@ public class PropertyPreferenceWriter */ public static void save(final OutputStream stream) throws Exception { + Map allKeysWithPackages = getAllPropertyKeys(); + Preferences prefs = Preferences.userRoot().node("org/phoebus/ui"); + + String value = prefs.get("excluded_keys_from_settings_check", ""); + if (value.isEmpty()) value = allKeysWithPackages.get("org.phoebus.ui/excluded_keys_from_settings_check"); + if (!value.isEmpty()) excludedKeys = Arrays.stream(value.split(",")).map(String::trim).collect(Collectors.toSet()); + + value = prefs.get("excluded_packages_from_settings_check", ""); + if (value.isEmpty()) value = allKeysWithPackages.get("org.phoebus.ui/excluded_packages_from_settings_check"); + if (!value.isEmpty()) excludedPackages = Arrays.stream(value.split(",")).map(String::trim).collect(Collectors.toSet()); + try ( - final OutputStreamWriter out = new OutputStreamWriter(stream); + final OutputStreamWriter out = new OutputStreamWriter(stream) ) { out.append("# Preference settings
\n"); out.append("# Format:
\n"); out.append("# the.package.name/key=value
\n"); out.append("

# key=value in red are incorrect properties

\n"); - listSettings(getAllPropertyKeys(), out, Preferences.userRoot()); + listSettings(allKeysWithPackages, out, Preferences.userRoot()); out.append("
\n"); out.append("# End.
\n"); out.flush(); @@ -81,12 +96,10 @@ private static void formatSetting(Map allKeysWithPackages, final String keyFound = allKeysWithPackages.get(fullKey); boolean bNotFound = keyFound == null; - // ignore keys that can be used but not from preferences.properties - if (key.toLowerCase().contains("external_app") || - key.toLowerCase().contains("password") || - key.toLowerCase().contains("username")) { - bNotFound = false; - } + // exclude keys that must not be checked + boolean containsExcludedKeys = excludedKeys.stream().anyMatch(key::contains); + boolean containsExcludedPackages = excludedPackages.stream().anyMatch(fullKey::startsWith); + if (containsExcludedKeys || containsExcludedPackages) bNotFound = false; if (bNotFound) out.append("
"); out.append(escapeHtml(fullKey)) @@ -96,12 +109,12 @@ private static void formatSetting(Map allKeysWithPackages, final if (bNotFound) out.append("
"); } - private static Map getAllPropertyKeys() throws Exception + private static Map getAllPropertyKeys() { Map allKeysWithPackages = new HashMap<>(); String classpath = System.getProperty("java.class.path"); - String[] jars = classpath.split(System.getProperty("path.separator")); + String[] jars = classpath.split(File.pathSeparator); if (jars.length == 1) jars = getAllJarFromManifest(jars[0]); @@ -123,26 +136,25 @@ private static Map getAllPropertyKeys() throws Exception ); } } - } catch (IOException e) { - System.err.println("Error opening JAR : " + jarEntry); + } catch (IOException ex) { + logger.log(Level.WARNING, "Error opening JAR : " + jarEntry, ex); } } else if (jarEntry.endsWith("classes")) { Path startPath = Paths.get(jarEntry); String filePattern = "preferences.properties"; - System.out.println(startPath); try (Stream paths = Files.walk(startPath)) { paths.filter(path -> path.toString().endsWith(filePattern)) .forEach(path -> { try (InputStream inputStream = Files.newInputStream(path)) { parsePropertiesWithPackage(inputStream, path.getFileName().toString(), allKeysWithPackages); - } catch (IOException e) { - System.err.println("Error opening properties : " + path); + } catch (IOException ex) { + logger.log(Level.WARNING, "Error opening properties file : " + path, ex); } }); - } catch (IOException e) { - System.err.println("Error listing files in : " + startPath); + } catch (IOException ex) { + logger.log(Level.WARNING, "Error listing files in : " + startPath, ex); } } } @@ -168,13 +180,13 @@ private static String[] getAllJarFromManifest(String jarPath) { jars[iJar] = fullPath.toString(); } } else { - System.err.println("No Class-Path found in MANIFEST.MF."); + logger.log(Level.WARNING, "No Class-Path found in MANIFEST.MF " + jarPath); } } else { - System.err.println("MANIFEST.MF not found in the JAR."); + logger.log(Level.WARNING, "MANIFEST.MF not found in the JAR " + jarPath); } - } catch (IOException e) { - System.err.println("Error when reading the jar : " + jarPath); + } catch (IOException ex) { + logger.log(Level.WARNING, "Error when reading the jar : " + jarPath, ex); } return jars; @@ -192,7 +204,7 @@ private static void parsePropertiesWithPackage(InputStream inputStream, String f line = line.trim(); if (line.startsWith("#") && line.contains("Package")) { // Find package name - Pattern pattern = Pattern.compile("#\\s*Package\\s+([^\\s]+)"); + Pattern pattern = Pattern.compile("#\\s*Package\\s+(\\S+)"); Matcher matcher = pattern.matcher(line); if (matcher.find()) { packageName = matcher.group(1); @@ -202,7 +214,7 @@ private static void parsePropertiesWithPackage(InputStream inputStream, String f } } - if (content.length() > 0) { + if (!content.isEmpty()) { props.load(new ByteArrayInputStream(content.toString().getBytes())); } @@ -211,9 +223,8 @@ private static void parsePropertiesWithPackage(InputStream inputStream, String f String prefixedKey = (packageName != null) ? packageName + "/" + key : key; allKeysWithPackages.put(prefixedKey, props.getProperty(key)); } - } catch (IOException e) { - System.err.println("Error when reading file " + fileName); - e.printStackTrace(); + } catch (IOException ex) { + logger.log(Level.WARNING, "Error when reading file " + fileName, ex); } } diff --git a/core/ui/src/main/resources/phoebus_ui_preferences.properties b/core/ui/src/main/resources/phoebus_ui_preferences.properties index c2de97ebeb..f122b696c7 100644 --- a/core/ui/src/main/resources/phoebus_ui_preferences.properties +++ b/core/ui/src/main/resources/phoebus_ui_preferences.properties @@ -290,3 +290,7 @@ default_window_title=CS-Studio # For a system file use syntax; 'file:' # For a file served over http use syntax: 'http://' custom_css_styling= + +# Keywords that can be excluded from the settings check in the About Dialog +excluded_keys_from_settings_check=external_app,password,username +excluded_packages_from_settings_check=eu.ess,fr.cea From 3969688f27029e68115b84b0eca076595b070f62 Mon Sep 17 00:00:00 2001 From: wmliu Date: Thu, 18 Dec 2025 18:57:26 -0600 Subject: [PATCH 57/83] Modified the behavior of spinner widget to make 5x steps, 10x steps increments with key combination ctrl+ up/down(5x); ctrl + page up/page down(10x); ctrl + left/right (10x) --- .../controls_spinner_slider_scrollbar.bob | 25 ++++++++--------- .../javafx/widgets/SpinnerRepresentation.java | 28 +++++++++++++------ 2 files changed, 30 insertions(+), 23 deletions(-) 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..e104788862 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 Scrollbar Scrollbar @@ -45,7 +45,6 @@ 110 220 false - #.## Label_3 @@ -70,18 +69,17 @@ 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 + Page Up/Down keys will change the value by 10 x step size + To make it up for some keyboard that need Fn keys to activate Page Up/Down, one can also use 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 + 250 Scaled Slider_1 @@ -90,7 +88,6 @@ the keyboard. 429 419 61 - #.## false 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 931379c21a..a4592ed0f5 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 @@ -106,9 +106,19 @@ 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.isAltDown()) { + if(event.isControlDown()) { spinner.getValueFactory().increment(5); } else { @@ -118,17 +128,17 @@ else if(focused){ break; case PAGE_UP: if (!active) { - if(event.isAltDown()) { - spinner.getValueFactory().increment(50); + if(event.isControlDown()) { + spinner.getValueFactory().increment(10); } else { - spinner.getValueFactory().increment(10); + spinner.getValueFactory().increment(1); } } break; case DOWN: if (!active) { - if(event.isAltDown()) { + if(event.isControlDown()) { spinner.getValueFactory().decrement(5); } else { @@ -138,15 +148,15 @@ else if(focused){ break; case PAGE_DOWN: if (!active) { - if(event.isAltDown()) { - spinner.getValueFactory().decrement(50); + if(event.isControlDown()) { + spinner.getValueFactory().decrement(10); } else { - spinner.getValueFactory().decrement(10); + spinner.getValueFactory().decrement(1); } } break; - case ALT: + case CONTROL: setActive(false); break; default: From 251ed4accda8411914c7d7741f5adf6866be04f1 Mon Sep 17 00:00:00 2001 From: Gabriel Desmarchelier Date: Fri, 19 Dec 2025 11:15:29 +0100 Subject: [PATCH 58/83] create individual color properties for each tab --- .../model/widgets/NavigationTabsWidget.java | 25 ++++++++-- .../javafx/widgets/NavigationTabs.java | 48 ++++++++++++++----- .../widgets/NavigationTabsRepresentation.java | 28 ++++++++--- 3 files changed, 78 insertions(+), 23 deletions(-) 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..b774bf25ed 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 @@ -26,6 +26,7 @@ import org.csstudio.display.builder.model.ArrayWidgetProperty; import org.csstudio.display.builder.model.DisplayModel; import org.csstudio.display.builder.model.MacroizedWidgetProperty; +import org.csstudio.display.builder.model.Messages; import org.csstudio.display.builder.model.StructuredWidgetProperty; import org.csstudio.display.builder.model.Widget; import org.csstudio.display.builder.model.WidgetCategory; @@ -47,6 +48,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 +64,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,7 +87,9 @@ 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 */ @@ -88,6 +100,11 @@ 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 @@ -134,8 +151,8 @@ 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(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)); 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..e8e26b2e71 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,8 +73,15 @@ 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; /** Direction of tabs */ @@ -84,6 +92,7 @@ public static interface Listener deselected = Color.rgb(200, 200, 200); private Font font = null; + private int selected_tab = -1; /** Listener to selected tab * @@ -123,21 +132,33 @@ public void removeListener(final Listener listener) } /** @param tabs Tab labels */ - public void setTabs(final List tabs) + public void setTabNames(final List tab_names) + { + this.tab_names.clear(); + this.tab_names.addAll(tab_names); + updateTabs(); + } + + /** @param tabs Selected colors */ + public void setTabSelectedColors(final List tab_selected_colors) { - this.tabs.clear(); - this.tabs.addAll(tabs); + this.tab_selected_colors.clear(); + this.tab_selected_colors.addAll(tab_selected_colors); + updateTabs(); + } + + /** @param tabs Deselected colors */ + public void setTabDeselectedColors(final List tab_deselected_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; for (Node sibling : siblings) { 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..fd89598c3b 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); @@ -348,10 +349,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 +362,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); } } @@ -388,9 +393,20 @@ public void updateChanges() } 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.setTabNames(tab_names); + jfx_node.setTabSelectedColors(tab_selected_colors); + jfx_node.setTabDeselectedColors(tab_deselected_colors); } if (dirty_active_tab.checkAndClear()) jfx_node.selectTab(model_widget.propActiveTab().getValue()); From c0bef377a7321c737b38bb29e60bfe9477ac326d Mon Sep 17 00:00:00 2001 From: Gabriel Desmarchelier Date: Fri, 19 Dec 2025 11:40:28 +0100 Subject: [PATCH 59/83] create enable_individual_colors propert for navtabs and manage the widget accordingly --- .../model/widgets/NavigationTabsWidget.java | 19 ++++-- .../javafx/widgets/NavigationTabs.java | 65 +++++++++++++++++-- .../widgets/NavigationTabsRepresentation.java | 3 + 3 files changed, 79 insertions(+), 8 deletions(-) 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 b774bf25ed..65ae853686 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 @@ -8,6 +8,7 @@ package org.csstudio.display.builder.model.widgets; import static org.csstudio.display.builder.model.ModelPlugin.logger; +import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newBooleanPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propDirection; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFile; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFont; @@ -50,7 +51,7 @@ 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 */ + /** Widget descriptor */ public static final WidgetDescriptor WIDGET_DESCRIPTOR = new WidgetDescriptor("navtabs", WidgetCategory.STRUCTURE, "Navigation Tabs", @@ -118,15 +119,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; @@ -151,6 +155,7 @@ 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(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))); @@ -188,6 +193,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/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 e8e26b2e71..3ecbd7aab6 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 @@ -84,6 +84,9 @@ public static interface Listener /** 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; @@ -223,6 +226,15 @@ public void setTabSpacing(final int spacing) updateTabs(); } + /** @param enable per tab colors */ + public void setEnablePerTabColors(final boolean enabled) + { + if (enable_per_tab_colors == enabled) + return; + enable_per_tab_colors = enabled; + updateTabs(); + } + /** @param color Color for selected tab */ public void setSelectedColor(final Color color) { @@ -271,6 +283,9 @@ private void updateTabs() buttons.getStyleClass().add("navtab_tabregion"); // Create button for each tab + Color tmpColor = deselected; + WidgetColor tmpWidgetColor = null; + for (int i = 0; i < tab_names.size(); ++i) { final ToggleButton button = new ToggleButton(tab_names.get(i)); // Buttons without text vanish, creating a gap in the tab lineup. @@ -279,8 +294,31 @@ private void updateTabs() if (direction == Direction.HORIZONTAL) button.pseudoClassStateChanged(HORIZONTAL, true); + if (getSelectedTab() == i) { + button.setSelected(true); + // Set color to global "selected" color value + tmpColor = 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 == true) { + if (i < tab_selected_colors.size()) { + tmpWidgetColor = tab_selected_colors.get(i); + tmpColor = JFXUtil.convert(tmpWidgetColor); + } + } + } else { + // 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 == true) { + if (i < tab_deselected_colors.size()) { + tmpWidgetColor = tab_deselected_colors.get(i); + tmpColor = JFXUtil.convert(tmpWidgetColor); + } + } + } + // base color, '-fx-color', is either selected or deselected - button.setStyle("-fx-color: " + JFXUtil.webRGB(deselected)); + button.setStyle("-fx-color: " + JFXUtil.webRGB(tmpColor)); button.getStyleClass().add("navtab_button"); button.setMinSize(ButtonBase.USE_PREF_SIZE, ButtonBase.USE_PREF_SIZE); button.setPrefSize(tab_width, tab_height); @@ -299,12 +337,15 @@ private void handleTabSelection(final ToggleButton pressed, final boolean notify final ObservableList 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()) @@ -312,14 +353,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 == true) { + 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 == true) { + if(i < tab_deselected_colors.size()) { + tmpWidgetColor = tab_deselected_colors.get(i); + tmpColor = JFXUtil.convert(tmpWidgetColor); + } + } + button.setStyle("-fx-color: " + JFXUtil.webRGB(tmpColor)); } ++i; } 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 fd89598c3b..d550bb265b 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 @@ -165,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); @@ -188,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); @@ -387,6 +389,7 @@ 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())); From e96f8d67625235e85ad24f170babb4bbef774f29 Mon Sep 17 00:00:00 2001 From: Gabriel Desmarchelier Date: Fri, 19 Dec 2025 13:12:55 +0100 Subject: [PATCH 60/83] fix call to setTabNames in NavigationTabsDemo.java --- .../representation/javafx/sandbox/NavigationTabsDemo.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..251b10cffc 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 @@ -35,7 +35,7 @@ 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); + nav_tabs.setTabNames(tabs); nav_tabs.setTabSize(80, 40); nav_tabs.setTabSpacing(5); nav_tabs.getBodyPane().getChildren().setAll(new Label(" Go on, select something!")); From 6e1f01334955e8579bb1835f9cca40c0486e0ec6 Mon Sep 17 00:00:00 2001 From: wmliuanl <78451125+wmliuanl@users.noreply.github.com> Date: Fri, 19 Dec 2025 09:19:46 -0600 Subject: [PATCH 61/83] Update controls_spinner_slider_scrollbar.bob Change key combinations to remove ctrl + page up/down, because it behaves differently on two different system. --- .../resources/examples/controls_spinner_slider_scrollbar.bob | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 e104788862..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 @@ -72,8 +72,7 @@ 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 + Page Up/Down keys will change the value by 10 x step size - To make it up for some keyboard that need Fn keys to activate Page Up/Down, one can also use Ctrl Left/Right arrow keys to make changes at 10 x step szie + 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. 141 From 471890f06df6846c993617088e78d904fbd1d1a3 Mon Sep 17 00:00:00 2001 From: wmliuanl <78451125+wmliuanl@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:13:05 -0600 Subject: [PATCH 62/83] Update SpinnerRepresentation.java Remove the key combination of ctrl + page up/page down which doesn't actually work on a test system --- .../javafx/widgets/SpinnerRepresentation.java | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) 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 a4592ed0f5..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 @@ -128,12 +128,7 @@ else if(focused){ break; case PAGE_UP: if (!active) { - if(event.isControlDown()) { - spinner.getValueFactory().increment(10); - } - else { - spinner.getValueFactory().increment(1); - } + spinner.getValueFactory().increment(1); } break; case DOWN: @@ -148,12 +143,7 @@ else if(focused){ break; case PAGE_DOWN: if (!active) { - if(event.isControlDown()) { - spinner.getValueFactory().decrement(10); - } - else { - spinner.getValueFactory().decrement(1); - } + spinner.getValueFactory().decrement(1); } break; case CONTROL: @@ -652,3 +642,4 @@ private void setActive(final boolean active) updateChanges(); } } + From d4a8dfb1f057713cf437418baad88158f185c6ba Mon Sep 17 00:00:00 2001 From: shroffk Date: Mon, 22 Dec 2025 15:04:34 -0500 Subject: [PATCH 63/83] a prototype context menu action for adding PVs to alarm configurations --- .../ui/tree/ContextMenuAddComponentPVs.java | 273 ++++++++++++++++++ .../org.phoebus.ui.spi.ContextMenuEntry | 1 + 2 files changed, 274 insertions(+) create mode 100644 app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ContextMenuAddComponentPVs.java create mode 100644 app/alarm/ui/src/main/resources/META-INF/services/org.phoebus.ui.spi.ContextMenuEntry 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..3692861083 --- /dev/null +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/tree/ContextMenuAddComponentPVs.java @@ -0,0 +1,273 @@ +package org.phoebus.applications.alarm.ui.tree; + +import javafx.beans.value.ChangeListener; +import javafx.geometry.Insets; +import javafx.scene.Node; +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.applications.alarm.ui.AlarmURI; +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.net.URI; +import java.util.List; +import java.util.Optional; +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; + + private String server = null; + private String config_name = null; + private AlarmClient client = null; + + @Override + public String getName() { + return "Add Component"; + } + + @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(); + server = AlarmSystem.server; + config_name = AlarmSystem.config_name; + + client = new AlarmClient(server, config_name, AlarmSystem.kafka_properties); + + AddComponentPVsDialog addDialog = new AddComponentPVsDialog(client, + pvs.stream().map(ProcessVariable::getName).collect(Collectors.toList()), null); + DialogResult dialogResult = addDialog.showAndGetResult(); + if (dialogResult == null) { + // User cancelled + return; + } + String path = dialogResult.path; + List pvNames = dialogResult.pvNames; + + if (AlarmTreeHelper.validateNewPath(path, client.getRoot())) { + try { + pvNames.forEach(pvName -> client.addPV(path, pvName)); + } catch (Exception ex) { + logger.log(Level.WARNING, "Cannot add component PVs to " + path, ex); + ExceptionDetailsErrorDialog.openError("Add Component PVs Failed", + "Failed to add component PVs to " + path, + ex); + } + } else { + // Show error dialog and retry + ExceptionDetailsErrorDialog.openError("Invalid Path", + "Invalid path. Please try again.", + null); + } + + } + + private Node create(final URI input, String itemName) throws Exception { + final String[] parsed = AlarmURI.parseAlarmURI(input); + String server = parsed[0]; + String config_name = parsed[1]; + + try { + AlarmClient client = new AlarmClient(server, config_name, AlarmSystem.kafka_properties); + final AlarmTreeConfigView tree_view = new AlarmTreeConfigView(client, itemName); + client.start(); + + if (AlarmSystem.config_names.length > 0) { + final AlarmConfigSelector configs = new AlarmConfigSelector(config_name, this::changeConfig); + tree_view.getToolbar().getItems().add(0, configs); + } + + return tree_view; + } catch (final Exception ex) { + logger.log(Level.WARNING, "Cannot create alarm tree for " + input, ex); + return new Label("Cannot create alarm tree for " + input); + } + } + + private void changeConfig(final String new_config_name) { + // Dispose existing setup + dispose(); + + try { + // Use same server name, but new config_name + final URI new_input = AlarmURI.createURI(server, new_config_name); + // no need for initial item name +// tab.setContent(create(new_input, null)); +// tab.setInput(new_input); +// Platform.runLater(() -> tab.setLabel(config_name + " " + app.getDisplayName())); + } catch (Exception ex) { + logger.log(Level.WARNING, "Cannot switch alarm tree to " + config_name, ex); + } + } + + private void dispose() { + if (client != null) { + client.shutdown(); + client = null; + } + } + + private static class AddComponentPVsDialog extends Dialog { + private final TextArea pvNamesInput; + private final TextField pathInput; + + public AddComponentPVsDialog(AlarmClient alarmClient, List initialPVs, String currentPath) { + setTitle("Add PVs to Alarm Configuration"); + setHeaderText("Select PVs and destination path"); + setResizable(true); + + // Create content + VBox 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 (initialPVs != null && !initialPVs.isEmpty()) { + pvNamesInput.setText(String.join("; ", initialPVs)); + } + + // Add AlarmTreeConfigView for path selection + Label treeLabel = new Label("Select destination in alarm tree:"); + AlarmTreeConfigView configView = new AlarmTreeConfigView(alarmClient); + configView.setPrefHeight(300); + configView.setPrefWidth(500); + alarmClient.start(); + + // 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); + + // 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 path input is not focused + if (!pathInput.isFocused()) { + pathInput.setText(selectedPath); + } + } + } + }; + configView.addTreeSelectionListener(selectionListener); + + // Remove the listener when the dialog is closed + this.setOnHidden(e -> configView.removeTreeSelectionListener(selectionListener)); + + // Add all components to layout + content.getChildren().addAll( + pvLabel, + pvNamesInput, + treeLabel, + 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(600, 700); + + // Set result converter - returns path if OK, null if Cancel + setResultConverter(buttonType -> { + if (buttonType == ButtonType.OK) { + String path = pathInput.getText().trim(); + if (path.isEmpty()) { + ExceptionDetailsErrorDialog.openError("Invalid Path", + "Destination path cannot be empty.", + null); + return null; + } + return path; + } + return null; + }); + } + + /** + * Get the list of PV names entered by the user + * + * @return List of PV names (trimmed and non-empty) + */ + public 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()); + } + + /** + * Show the dialog and get both the path and PV names + * + * @return DialogResult containing path and PV names, or null if cancelled + */ + public DialogResult showAndGetResult() { + Optional result = showAndWait(); + if (result.isPresent()) { + return new DialogResult(result.get(), getPVNames()); + } + return null; + } + } + + /** + * Result from AddComponentPVsDialog containing both path and PV names + */ + private static class DialogResult { + final String path; + final List pvNames; + + DialogResult(String path, List pvNames) { + this.path = path; + this.pvNames = pvNames; + } + } +} 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 From c632ccba6a13ee897ed722ad63978ea5e95b050e Mon Sep 17 00:00:00 2001 From: shroffk Date: Wed, 31 Dec 2025 14:05:59 -0500 Subject: [PATCH 64/83] Add support for changing condifurations --- .../ui/tree/ContextMenuAddComponentPVs.java | 158 +++++++++--------- 1 file changed, 80 insertions(+), 78 deletions(-) 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 index 3692861083..a5b93be6ac 100644 --- 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 @@ -1,5 +1,6 @@ package org.phoebus.applications.alarm.ui.tree; +import javafx.application.Platform; import javafx.beans.value.ChangeListener; import javafx.geometry.Insets; import javafx.scene.Node; @@ -35,10 +36,6 @@ public class ContextMenuAddComponentPVs implements ContextMenuEntry { private static final Class supportedTypes = ProcessVariable.class; - private String server = null; - private String config_name = null; - private AlarmClient client = null; - @Override public String getName() { return "Add Component"; @@ -57,89 +54,43 @@ public Class getSupportedType() { @Override public void call(Selection selection) throws Exception { List pvs = selection.getSelections(); - server = AlarmSystem.server; - config_name = AlarmSystem.config_name; - client = new AlarmClient(server, config_name, AlarmSystem.kafka_properties); + AddComponentPVsDialog addDialog = new AddComponentPVsDialog(AlarmSystem.server, + AlarmSystem.config_name, + AlarmSystem.kafka_properties, + pvs.stream().map(ProcessVariable::getName).collect(Collectors.toList()), + null); - AddComponentPVsDialog addDialog = new AddComponentPVsDialog(client, - pvs.stream().map(ProcessVariable::getName).collect(Collectors.toList()), null); DialogResult dialogResult = addDialog.showAndGetResult(); if (dialogResult == null) { // User cancelled return; } - String path = dialogResult.path; - List pvNames = dialogResult.pvNames; - - if (AlarmTreeHelper.validateNewPath(path, client.getRoot())) { - try { - pvNames.forEach(pvName -> client.addPV(path, pvName)); - } catch (Exception ex) { - logger.log(Level.WARNING, "Cannot add component PVs to " + path, ex); - ExceptionDetailsErrorDialog.openError("Add Component PVs Failed", - "Failed to add component PVs to " + path, - ex); - } - } else { - // Show error dialog and retry - ExceptionDetailsErrorDialog.openError("Invalid Path", - "Invalid path. Please try again.", - null); - } - - } - - private Node create(final URI input, String itemName) throws Exception { - final String[] parsed = AlarmURI.parseAlarmURI(input); - String server = parsed[0]; - String config_name = parsed[1]; - - try { - AlarmClient client = new AlarmClient(server, config_name, AlarmSystem.kafka_properties); - final AlarmTreeConfigView tree_view = new AlarmTreeConfigView(client, itemName); - client.start(); - - if (AlarmSystem.config_names.length > 0) { - final AlarmConfigSelector configs = new AlarmConfigSelector(config_name, this::changeConfig); - tree_view.getToolbar().getItems().add(0, configs); - } - - return tree_view; - } catch (final Exception ex) { - logger.log(Level.WARNING, "Cannot create alarm tree for " + input, ex); - return new Label("Cannot create alarm tree for " + input); - } - } - - private void changeConfig(final String new_config_name) { - // Dispose existing setup - dispose(); - - try { - // Use same server name, but new config_name - final URI new_input = AlarmURI.createURI(server, new_config_name); - // no need for initial item name -// tab.setContent(create(new_input, null)); -// tab.setInput(new_input); -// Platform.runLater(() -> tab.setLabel(config_name + " " + app.getDisplayName())); - } catch (Exception ex) { - logger.log(Level.WARNING, "Cannot switch alarm tree to " + config_name, ex); - } - } - - private void dispose() { - if (client != null) { - client.shutdown(); - client = null; - } } + /** + * Dialog for adding component PVs to an alarm configuration + */ private static class AddComponentPVsDialog extends Dialog { private final TextArea pvNamesInput; private final TextField pathInput; - public AddComponentPVsDialog(AlarmClient alarmClient, List initialPVs, String currentPath) { + private AlarmClient alarmClient; + /** + * 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) { + // Model/Controller + + alarmClient = new AlarmClient(server, config_name, kafka_properties); + setTitle("Add PVs to Alarm Configuration"); setHeaderText("Select PVs and destination path"); setResizable(true); @@ -156,13 +107,19 @@ public AddComponentPVsDialog(AlarmClient alarmClient, List initialPVs, S pvNamesInput.setWrapText(true); // Pre-populate with initial PVs if provided - if (initialPVs != null && !initialPVs.isEmpty()) { - pvNamesInput.setText(String.join("; ", initialPVs)); + if (pvNames != null && !pvNames.isEmpty()) { + pvNamesInput.setText(String.join("; ", pvNames)); } // Add AlarmTreeConfigView for path selection Label treeLabel = new Label("Select destination in alarm tree:"); AlarmTreeConfigView configView = new AlarmTreeConfigView(alarmClient); + + if (AlarmSystem.config_names.length > 0) { + final AlarmConfigSelector configs = new AlarmConfigSelector(config_name, this::changeConfig); + configView.getToolbar().getItems().add(0, configs); + } + configView.setPrefHeight(300); configView.setPrefWidth(500); alarmClient.start(); @@ -189,8 +146,11 @@ public AddComponentPVsDialog(AlarmClient alarmClient, List initialPVs, S }; configView.addTreeSelectionListener(selectionListener); - // Remove the listener when the dialog is closed - this.setOnHidden(e -> configView.removeTreeSelectionListener(selectionListener)); + // Remove the listener and dispose AlarmClient when the dialog is closed + this.setOnHidden(e -> { + configView.removeTreeSelectionListener(selectionListener); + dispose(); + }); // Add all components to layout content.getChildren().addAll( @@ -219,12 +179,54 @@ public AddComponentPVsDialog(AlarmClient alarmClient, List initialPVs, S null); return null; } + if (AlarmTreeHelper.validateNewPath(path, alarmClient.getRoot())) { + try { + getPVNames().forEach(pvName -> alarmClient.addPV(path, pvName)); + } catch (Exception ex) { + logger.log(Level.WARNING, "Cannot add component PVs to " + path, ex); + ExceptionDetailsErrorDialog.openError("Add Component PVs Failed", + "Failed to add component PVs to " + path, + ex); + } + } else { + // Show error dialog and retry + ExceptionDetailsErrorDialog.openError("Invalid Path", + "Invalid path. Please try again.", + null); + } return path; } return null; }); } + private void changeConfig(String new_config_name) { + // Dispose existing setup + dispose(); + + try + { + // Use same server name, but new config_name + final URI new_input = AlarmURI.createURI(AlarmSystem.server, new_config_name); + // no need for initial item name + alarmClient = new AlarmClient(AlarmSystem.server, new_config_name, AlarmSystem.kafka_properties); + AlarmTreeConfigView configView = new AlarmTreeConfigView(alarmClient); + alarmClient.start(); + } + catch (Exception ex) + { + logger.log(Level.WARNING, "Cannot switch alarm tree to " + new_config_name, ex); + } + } + + private void dispose() + { + if (alarmClient != null) + { + alarmClient.shutdown(); + alarmClient = null; + } + } /** * Get the list of PV names entered by the user * From 14027951df84df92a664e3846d1463cff76497bd Mon Sep 17 00:00:00 2001 From: shroffk Date: Wed, 31 Dec 2025 14:12:46 -0500 Subject: [PATCH 65/83] Cleanup the dialog, the AlarmClient lifecycle is now completely within the dialog and not shared with the action --- .../ui/tree/ContextMenuAddComponentPVs.java | 44 +++---------------- 1 file changed, 5 insertions(+), 39 deletions(-) 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 index a5b93be6ac..0d5eb0c4fe 100644 --- 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 @@ -1,9 +1,7 @@ package org.phoebus.applications.alarm.ui.tree; -import javafx.application.Platform; import javafx.beans.value.ChangeListener; import javafx.geometry.Insets; -import javafx.scene.Node; import javafx.scene.control.ButtonType; import javafx.scene.control.Dialog; import javafx.scene.control.Label; @@ -26,7 +24,6 @@ import java.net.URI; import java.util.List; -import java.util.Optional; import java.util.logging.Level; import java.util.stream.Collectors; @@ -61,17 +58,13 @@ public void call(Selection selection) throws Exception { pvs.stream().map(ProcessVariable::getName).collect(Collectors.toList()), null); - DialogResult dialogResult = addDialog.showAndGetResult(); - if (dialogResult == null) { - // User cancelled - return; - } + addDialog.showAndWait(); } /** * Dialog for adding component PVs to an alarm configuration */ - private static class AddComponentPVsDialog extends Dialog { + private static class AddComponentPVsDialog extends Dialog { private final TextArea pvNamesInput; private final TextField pathInput; @@ -169,7 +162,7 @@ public AddComponentPVsDialog(String server, String config_name, String kafka_pro getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); getDialogPane().setPrefSize(600, 700); - // Set result converter - returns path if OK, null if Cancel + // Set result converter - handles PV addition and returns null setResultConverter(buttonType -> { if (buttonType == ButtonType.OK) { String path = pathInput.getText().trim(); @@ -189,12 +182,11 @@ public AddComponentPVsDialog(String server, String config_name, String kafka_pro ex); } } else { - // Show error dialog and retry + // Show error dialog ExceptionDetailsErrorDialog.openError("Invalid Path", "Invalid path. Please try again.", null); } - return path; } return null; }); @@ -232,7 +224,7 @@ private void dispose() * * @return List of PV names (trimmed and non-empty) */ - public List getPVNames() { + private List getPVNames() { String text = pvNamesInput.getText(); if (text == null || text.trim().isEmpty()) { return List.of(); @@ -245,31 +237,5 @@ public List getPVNames() { .filter(s -> !s.isEmpty()) .collect(Collectors.toList()); } - - /** - * Show the dialog and get both the path and PV names - * - * @return DialogResult containing path and PV names, or null if cancelled - */ - public DialogResult showAndGetResult() { - Optional result = showAndWait(); - if (result.isPresent()) { - return new DialogResult(result.get(), getPVNames()); - } - return null; - } - } - - /** - * Result from AddComponentPVsDialog containing both path and PV names - */ - private static class DialogResult { - final String path; - final List pvNames; - - DialogResult(String path, List pvNames) { - this.path = path; - this.pvNames = pvNames; - } } } From 95c2b752c883549e8b91f4a3d68c71d1186001b9 Mon Sep 17 00:00:00 2001 From: shroffk Date: Wed, 31 Dec 2025 14:26:06 -0500 Subject: [PATCH 66/83] The "add pvs to alarm tree" supports multiple configuration --- .../ui/tree/ContextMenuAddComponentPVs.java | 135 +++++++++++------- 1 file changed, 82 insertions(+), 53 deletions(-) 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 index 0d5eb0c4fe..71c6e33d67 100644 --- 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 @@ -15,14 +15,12 @@ import org.phoebus.applications.alarm.client.AlarmClient; import org.phoebus.applications.alarm.model.AlarmTreeItem; import org.phoebus.applications.alarm.ui.AlarmConfigSelector; -import org.phoebus.applications.alarm.ui.AlarmURI; 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.net.URI; import java.util.List; import java.util.logging.Level; import java.util.stream.Collectors; @@ -67,8 +65,15 @@ public void call(Selection selection) throws Exception { 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 * @@ -80,16 +85,15 @@ private static class AddComponentPVsDialog extends Dialog { */ public AddComponentPVsDialog(String server, String config_name, String kafka_properties, List pvNames, String currentPath) { - // Model/Controller - - alarmClient = new AlarmClient(server, config_name, kafka_properties); + this.server = server; + this.kafka_properties = kafka_properties; setTitle("Add PVs to Alarm Configuration"); setHeaderText("Select PVs and destination path"); setResizable(true); // Create content - VBox content = new VBox(10); + content = new VBox(10); content.setPadding(new Insets(15)); // PV Names input section @@ -104,18 +108,8 @@ public AddComponentPVsDialog(String server, String config_name, String kafka_pro pvNamesInput.setText(String.join("; ", pvNames)); } - // Add AlarmTreeConfigView for path selection - Label treeLabel = new Label("Select destination in alarm tree:"); - AlarmTreeConfigView configView = new AlarmTreeConfigView(alarmClient); - - if (AlarmSystem.config_names.length > 0) { - final AlarmConfigSelector configs = new AlarmConfigSelector(config_name, this::changeConfig); - configView.getToolbar().getItems().add(0, configs); - } - - configView.setPrefHeight(300); - configView.setPrefWidth(500); - alarmClient.start(); + // Tree label + treeLabel = new Label("Select destination in alarm tree:"); // Path input Label pathLabel = new Label("Destination Path:"); @@ -125,32 +119,18 @@ public AddComponentPVsDialog(String server, String config_name, String kafka_pro pathInput.setPromptText("Select a path from the tree above or type manually"); pathInput.setEditable(true); - // 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 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(); - }); - - // Add all components to layout + // Add static components to layout content.getChildren().addAll( pvLabel, pvNamesInput, - treeLabel, - configView, + treeLabel + ); + + // Create initial tree view + createTreeView(config_name); + + // Add path input section + content.getChildren().addAll( pathLabel, pathInput ); @@ -192,22 +172,71 @@ public AddComponentPVsDialog(String server, String config_name, String kafka_pro }); } + 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 setup + // Dispose existing client dispose(); - try - { - // Use same server name, but new config_name - final URI new_input = AlarmURI.createURI(AlarmSystem.server, new_config_name); - // no need for initial item name - alarmClient = new AlarmClient(AlarmSystem.server, new_config_name, AlarmSystem.kafka_properties); - AlarmTreeConfigView configView = new AlarmTreeConfigView(alarmClient); - alarmClient.start(); - } - catch (Exception ex) - { + 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); } } From 9d1c23de2dbae705006ecc953e044ab93f2ef9ad Mon Sep 17 00:00:00 2001 From: shroffk Date: Mon, 5 Jan 2026 11:02:20 -0500 Subject: [PATCH 67/83] move the add PV action to the dialog instead of the action --- .../ui/tree/ContextMenuAddComponentPVs.java | 71 ++++++++++++------- 1 file changed, 45 insertions(+), 26 deletions(-) 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 index 71c6e33d67..1604103d32 100644 --- 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 @@ -142,33 +142,52 @@ public AddComponentPVsDialog(String server, String config_name, String kafka_pro getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); getDialogPane().setPrefSize(600, 700); - // Set result converter - handles PV addition and returns null - setResultConverter(buttonType -> { - if (buttonType == ButtonType.OK) { - String path = pathInput.getText().trim(); - if (path.isEmpty()) { - ExceptionDetailsErrorDialog.openError("Invalid Path", - "Destination path cannot be empty.", - null); - return null; - } - if (AlarmTreeHelper.validateNewPath(path, alarmClient.getRoot())) { - try { - getPVNames().forEach(pvName -> alarmClient.addPV(path, pvName)); - } catch (Exception ex) { - logger.log(Level.WARNING, "Cannot add component PVs to " + path, ex); - ExceptionDetailsErrorDialog.openError("Add Component PVs Failed", - "Failed to add component PVs to " + path, - ex); - } - } else { - // Show error dialog - ExceptionDetailsErrorDialog.openError("Invalid Path", - "Invalid path. Please try again.", - null); - } + // 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 + try { + pvNamesToAdd.forEach(pvName -> alarmClient.addPV(path, pvName)); + logger.log(Level.INFO, "Successfully added " + pvNamesToAdd.size() + " PV(s) to " + path); + } catch (Exception ex) { + event.consume(); // Prevent dialog from closing + logger.log(Level.WARNING, "Cannot add component PVs to " + path, ex); + ExceptionDetailsErrorDialog.openError("Add Component PVs Failed", + "Failed to add PVs to path: " + path + "\n\n" + + "PVs attempted: " + String.join(", ", pvNamesToAdd) + "\n\n" + + "Error: " + ex.getMessage(), + ex); } - return null; }); } From eb15e540d2b96280ab4c538630e2d5f4add3394a Mon Sep 17 00:00:00 2001 From: shroffk Date: Mon, 5 Jan 2026 13:16:08 -0500 Subject: [PATCH 68/83] More clear name for the context menu action to add PVs to the alarm system --- .../applications/alarm/ui/tree/ContextMenuAddComponentPVs.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1604103d32..ffb04ee627 100644 --- 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 @@ -33,7 +33,7 @@ public class ContextMenuAddComponentPVs implements ContextMenuEntry { @Override public String getName() { - return "Add Component"; + return "Add PVs to Alarm System"; } @Override From 400445a925a7fed596a2b10c80f00c9978e2af0d Mon Sep 17 00:00:00 2001 From: Gabriel Desmarchelier Date: Wed, 7 Jan 2026 16:45:52 +0100 Subject: [PATCH 69/83] NavigationTabs : fix minor typo issues --- .../javafx/widgets/NavigationTabs.java | 12 ++++++------ .../javafx/widgets/NavigationTabsRepresentation.java | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) 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 3ecbd7aab6..7c3644fbaa 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 @@ -299,7 +299,7 @@ private void updateTabs() // Set color to global "selected" color value tmpColor = 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 == true) { + if (enable_per_tab_colors) { if (i < tab_selected_colors.size()) { tmpWidgetColor = tab_selected_colors.get(i); tmpColor = JFXUtil.convert(tmpWidgetColor); @@ -309,7 +309,7 @@ private void updateTabs() // 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 == true) { + if (enable_per_tab_colors) { if (i < tab_deselected_colors.size()) { tmpWidgetColor = tab_deselected_colors.get(i); tmpColor = JFXUtil.convert(tmpWidgetColor); @@ -354,8 +354,8 @@ private void handleTabSelection(final ToggleButton pressed, final boolean notify } // Highlight active tab by setting it to the 'selected' color // 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 == true) { - if(i < tab_selected_colors.size()) { + if (enable_per_tab_colors) { + if (i < tab_selected_colors.size()) { tmpWidgetColor = tab_selected_colors.get(i); tmpColor = JFXUtil.convert(tmpWidgetColor); } @@ -370,8 +370,8 @@ else if (button.isSelected()) // 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 == true) { - if(i < tab_deselected_colors.size()) { + if (enable_per_tab_colors) { + if (i < tab_deselected_colors.size()) { tmpWidgetColor = tab_deselected_colors.get(i); tmpColor = JFXUtil.convert(tmpWidgetColor); } 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 d550bb265b..46f7dd9545 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 @@ -397,9 +397,9 @@ public void updateChanges() if (dirty_tabs.checkAndClear()) { final List tab_names = new ArrayList<>(); - final List tab_selected_colors = new ArrayList<>(); - final List tab_deselected_colors = 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()); From e4041387cad59b3a66389a93aa8df91fa766b75c Mon Sep 17 00:00:00 2001 From: Gabriel Desmarchelier Date: Wed, 7 Jan 2026 16:50:18 +0100 Subject: [PATCH 70/83] NavigationTabs : indentation is consistent in using 4 spaces --- .../model/widgets/NavigationTabsWidget.java | 6 +- .../javafx/widgets/NavigationTabs.java | 74 +++++++++---------- .../widgets/NavigationTabsRepresentation.java | 2 +- 3 files changed, 41 insertions(+), 41 deletions(-) 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 65ae853686..ec723afa38 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 @@ -49,9 +49,9 @@ */ 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 */ + 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, "Navigation Tabs", 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 7c3644fbaa..4553c841bc 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 @@ -286,36 +286,36 @@ private void updateTabs() Color tmpColor = deselected; WidgetColor tmpWidgetColor = null; - for (int i = 0; i < tab_names.size(); ++i) { - final ToggleButton button = new ToggleButton(tab_names.get(i)); + for (int i = 0; i < tab_names.size(); ++i) { + final ToggleButton button = new ToggleButton(tab_names.get(i)); // Buttons without text vanish, creating a gap in the tab lineup. if (button.getText().isEmpty()) button.setVisible(false); if (direction == Direction.HORIZONTAL) button.pseudoClassStateChanged(HORIZONTAL, true); - if (getSelectedTab() == i) { - button.setSelected(true); - // Set color to global "selected" color value - tmpColor = 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); - } - } - } else { - // 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); - } - } - } + if (getSelectedTab() == i) { + button.setSelected(true); + // Set color to global "selected" color value + tmpColor = 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); + } + } + } else { + // 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); + } + } + } // base color, '-fx-color', is either selected or deselected button.setStyle("-fx-color: " + JFXUtil.webRGB(tmpColor)); @@ -344,8 +344,8 @@ private void handleTabSelection(final ToggleButton pressed, final boolean notify final ToggleButton button = (ToggleButton) sibling; if (button == pressed) { - // Set color to global "selected" color value - tmpColor = selected; + // 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()) @@ -355,10 +355,10 @@ private void handleTabSelection(final ToggleButton pressed, final boolean notify // Highlight active tab by setting it to the 'selected' color // 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); - } + 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; @@ -367,14 +367,14 @@ else if (button.isSelected()) { // Radio-button behavior: De-select other tabs button.setSelected(false); - // 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 + // 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); - } + if (i < tab_deselected_colors.size()) { + tmpWidgetColor = tab_deselected_colors.get(i); + tmpColor = JFXUtil.convert(tmpWidgetColor); + } } button.setStyle("-fx-color: " + JFXUtil.webRGB(tmpColor)); } 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 46f7dd9545..81eb2346e9 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 @@ -351,7 +351,7 @@ private void removeTabs(final List removed) { for (TabProperty tab : removed) { - tab.name().removePropertyListener(tabs_listener); + tab.name().removePropertyListener(tabs_listener); tab.file().removePropertyListener(tab_display_listener); tab.macros().removePropertyListener(tab_display_listener); tab.group().removePropertyListener(tab_display_listener); From f425702e5b39459cfd2cc981566bf706446a9def Mon Sep 17 00:00:00 2001 From: Gabriel Desmarchelier Date: Wed, 7 Jan 2026 17:28:39 +0100 Subject: [PATCH 71/83] NavigationTabs : using a single setTabs method to update Tabs --- .../javafx/widgets/NavigationTabs.java | 16 ++-------------- .../widgets/NavigationTabsRepresentation.java | 4 +--- 2 files changed, 3 insertions(+), 17 deletions(-) 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 4553c841bc..d8cf6774fd 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 @@ -134,25 +134,13 @@ public void removeListener(final Listener listener) this.listener = null; } - /** @param tabs Tab labels */ - public void setTabNames(final List tab_names) + /** @param tabs Tabs */ + public void setTabs(final List tab_names, final List tab_selected_colors, final List tab_deselected_colors) { this.tab_names.clear(); this.tab_names.addAll(tab_names); - updateTabs(); - } - - /** @param tabs Selected colors */ - public void setTabSelectedColors(final List tab_selected_colors) - { this.tab_selected_colors.clear(); this.tab_selected_colors.addAll(tab_selected_colors); - updateTabs(); - } - - /** @param tabs Deselected colors */ - public void setTabDeselectedColors(final List tab_deselected_colors) - { this.tab_deselected_colors.clear(); this.tab_deselected_colors.addAll(tab_deselected_colors); updateTabs(); 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 81eb2346e9..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 @@ -407,9 +407,7 @@ public void updateChanges() tab_deselected_colors.add(tab.individual_deselected_color().getValue()); }); - jfx_node.setTabNames(tab_names); - jfx_node.setTabSelectedColors(tab_selected_colors); - jfx_node.setTabDeselectedColors(tab_deselected_colors); + jfx_node.setTabs(tab_names, tab_selected_colors, tab_deselected_colors); } if (dirty_active_tab.checkAndClear()) jfx_node.selectTab(model_widget.propActiveTab().getValue()); From 6e6247c5588b768c95f95e4e016c58b895f201ee Mon Sep 17 00:00:00 2001 From: Gabriel Desmarchelier Date: Wed, 7 Jan 2026 17:34:45 +0100 Subject: [PATCH 72/83] NavigatonTabs : fix minor typo issues --- .../display/builder/model/widgets/NavigationTabsWidget.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 ec723afa38..945e318e78 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 @@ -91,7 +91,7 @@ public TabProperty(final Widget widget, final int index) propGroupName.createProperty(widget, ""), propIndividualSelectedColor.createProperty(widget, DEFAULT_SELECT_COLOR), propIndividualDeselectedColor.createProperty(widget, DEFAULT_DESELECT_COLOR) - )); + )); } /** @return Tab name */ public WidgetProperty name() { return getElement(0); } @@ -105,7 +105,6 @@ public TabProperty(final Widget widget, final int index) public WidgetProperty individual_selected_color() { return getElement(4); } /** @return Tab color when not selected */ public WidgetProperty individual_deselected_color() { return getElement(5); } - } // 'tabs' array @@ -274,4 +273,4 @@ public Macros getEffectiveMacros() } return base; } -} +} \ No newline at end of file From 272a54073fe15451d7767128e070901d671d0b74 Mon Sep 17 00:00:00 2001 From: Gabriel Desmarchelier Date: Wed, 7 Jan 2026 17:36:06 +0100 Subject: [PATCH 73/83] remove Messages import --- .../display/builder/model/widgets/NavigationTabsWidget.java | 1 - 1 file changed, 1 deletion(-) 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 945e318e78..0caa566aba 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 @@ -27,7 +27,6 @@ import org.csstudio.display.builder.model.ArrayWidgetProperty; import org.csstudio.display.builder.model.DisplayModel; import org.csstudio.display.builder.model.MacroizedWidgetProperty; -import org.csstudio.display.builder.model.Messages; import org.csstudio.display.builder.model.StructuredWidgetProperty; import org.csstudio.display.builder.model.Widget; import org.csstudio.display.builder.model.WidgetCategory; From 7f4f7261c676e7ee48e5ec33a47aea4912db47b3 Mon Sep 17 00:00:00 2001 From: Gabriel Desmarchelier Date: Wed, 7 Jan 2026 17:39:52 +0100 Subject: [PATCH 74/83] NavigatonTabs : fix minor typo issues --- .../display/builder/model/widgets/NavigationTabsWidget.java | 2 +- .../builder/representation/javafx/widgets/NavigationTabs.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 0caa566aba..cd2370a5e5 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 @@ -272,4 +272,4 @@ public Macros getEffectiveMacros() } return base; } -} \ No newline at end of file +} 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 d8cf6774fd..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 @@ -273,7 +273,7 @@ private void updateTabs() // Create button for each tab Color tmpColor = deselected; WidgetColor tmpWidgetColor = null; - + for (int i = 0; i < tab_names.size(); ++i) { final ToggleButton button = new ToggleButton(tab_names.get(i)); // Buttons without text vanish, creating a gap in the tab lineup. @@ -373,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 +} From 666a19e7361c271e789846afaad138766ca670db Mon Sep 17 00:00:00 2001 From: Gabriel Desmarchelier Date: Wed, 7 Jan 2026 17:51:15 +0100 Subject: [PATCH 75/83] NavigationTabs : fix NavigationTabsDemo.java --- .../representation/javafx/sandbox/NavigationTabsDemo.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 251b10cffc..b68cb16986 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 @@ -35,7 +35,7 @@ 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.setTabNames(tabs); + nav_tabs.setTabs(tabs); nav_tabs.setTabSize(80, 40); nav_tabs.setTabSpacing(5); nav_tabs.getBodyPane().getChildren().setAll(new Label(" Go on, select something!")); From 010be78567af1ef8481f99bc6c78e1f10fbfbaa1 Mon Sep 17 00:00:00 2001 From: Abraham Wolk Date: Thu, 8 Jan 2026 11:33:16 +0100 Subject: [PATCH 76/83] CSSTUDIO-3605 Report error for unsupported number formats when converting to HEX representation. --- .../phoebus/ui/vtype/FormatOptionHandler.java | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java b/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java index 6f654f6e8b..9c49d2b58d 100644 --- a/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java +++ b/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java @@ -13,6 +13,7 @@ import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; import java.util.logging.Logger; @@ -223,36 +224,46 @@ private static String formatNumber(final Number value, final Display display, if (option == FormatOption.HEX) { final StringBuilder buf = new StringBuilder(); - long longValue; + Optional maybeLongValue; if (value instanceof Byte valueByte) { - longValue = Byte.toUnsignedLong(valueByte); + maybeLongValue = Optional.of(Byte.toUnsignedLong(valueByte)); } else if (value instanceof Short valueShort) { - longValue = Short.toUnsignedLong(valueShort); + maybeLongValue = Optional.of(Short.toUnsignedLong(valueShort)); } else if (value instanceof Integer valueInt) { - longValue = Integer.toUnsignedLong(valueInt); + maybeLongValue = Optional.of(Integer.toUnsignedLong(valueInt)); } else if (value instanceof Long valueLong) { - longValue = valueLong; + maybeLongValue = Optional.of(valueLong); } else if (value instanceof Float valueFloat) { - longValue = valueFloat.longValue(); + maybeLongValue = Optional.of(valueFloat.longValue()); } else if (value instanceof Double valueDouble) { - longValue = valueDouble.longValue(); + maybeLongValue = Optional.of(valueDouble.longValue()); } else if (value instanceof UByte valueUByte) { - longValue = valueUByte.longValue(); + maybeLongValue = Optional.of(valueUByte.longValue()); } else if (value instanceof UShort valueUShort) { - longValue = valueUShort.longValue(); + maybeLongValue = Optional.of(valueUShort.longValue()); } else if (value instanceof UInteger valueUInteger) { - longValue = valueUInteger.longValue(); + maybeLongValue = Optional.of(valueUInteger.longValue()); } else if (value instanceof ULong valueULong) { - longValue = valueULong.longValue(); + maybeLongValue = Optional.of(valueULong.longValue()); } else { - longValue = value.longValue(); + maybeLongValue = Optional.empty(); } - String hexString = Long.toHexString(longValue); - buf.append(hexString.toUpperCase()); - for (int i=buf.length(); i Date: Thu, 8 Jan 2026 11:38:20 +0100 Subject: [PATCH 77/83] CSSTUDIO-3605 Improve typing and limit scope of 'buf'. --- .../java/org/phoebus/ui/vtype/FormatOptionHandler.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java b/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java index 9c49d2b58d..d4962514c4 100644 --- a/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java +++ b/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java @@ -223,8 +223,7 @@ private static String formatNumber(final Number value, final Display display, } if (option == FormatOption.HEX) { - final StringBuilder buf = new StringBuilder(); - Optional maybeLongValue; + final Optional maybeLongValue; if (value instanceof Byte valueByte) { maybeLongValue = Optional.of(Byte.toUnsignedLong(valueByte)); } else if (value instanceof Short valueShort) { @@ -248,17 +247,18 @@ private static String formatNumber(final Number value, final Display display, } else { maybeLongValue = Optional.empty(); } - + if (maybeLongValue.isPresent()) { long longValue = maybeLongValue.get(); - String hexString = Long.toHexString(longValue); + final String hexString = Long.toHexString(longValue); + final StringBuilder buf = new StringBuilder(); buf.append(hexString.toUpperCase()); for (int i=buf.length(); i Date: Thu, 8 Jan 2026 09:38:04 -0500 Subject: [PATCH 78/83] Handle the new AlarmTreePathException --- .../ui/tree/ContextMenuAddComponentPVs.java | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) 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 index ffb04ee627..7c6b6af104 100644 --- 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 @@ -175,18 +175,41 @@ public AddComponentPVsDialog(String server, String config_name, String kafka_pro return; } - // Try to add PVs - try { - pvNamesToAdd.forEach(pvName -> alarmClient.addPV(path, pvName)); - logger.log(Level.INFO, "Successfully added " + pvNamesToAdd.size() + " PV(s) to " + path); - } catch (Exception ex) { + // 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 - logger.log(Level.WARNING, "Cannot add component PVs to " + path, ex); - ExceptionDetailsErrorDialog.openError("Add Component PVs Failed", - "Failed to add PVs to path: " + path + "\n\n" + - "PVs attempted: " + String.join(", ", pvNamesToAdd) + "\n\n" + - "Error: " + ex.getMessage(), - ex); + 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); } }); } From 0e767e7d59c0b99f8ebe5d472f42598ee1d9aec2 Mon Sep 17 00:00:00 2001 From: georgweiss Date: Fri, 9 Jan 2026 11:36:53 +0100 Subject: [PATCH 79/83] Fix open save&restore action when UI is not yet open --- .../saveandrestore/ui/SaveAndRestoreController.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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; From fae59a986632909957ca2b8a2063941d53122c1c Mon Sep 17 00:00:00 2001 From: Gabriel Desmarchelier Date: Fri, 9 Jan 2026 17:16:49 +0100 Subject: [PATCH 80/83] NavigationTabs: fix build of tests --- .../builder/model/widgets/NavigationTabsWidget.java | 3 +-- .../javafx/sandbox/NavigationTabsDemo.java | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) 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 cd2370a5e5..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 @@ -8,7 +8,6 @@ package org.csstudio.display.builder.model.widgets; import static org.csstudio.display.builder.model.ModelPlugin.logger; -import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.newBooleanPropertyDescriptor; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propDirection; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFile; import static org.csstudio.display.builder.model.properties.CommonWidgetProperties.propFont; @@ -118,7 +117,7 @@ public TabProperty(final Widget widget, final int index) CommonWidgetProperties.newIntegerPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "tab_spacing", "Tab Spacing"); private static final WidgetPropertyDescriptor propEnablePerTabColors = - CommonWidgetProperties.newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "enable_per_tab_colors", "Per Tab Colors"); + CommonWidgetProperties.newBooleanPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "enable_per_tab_colors", "Per Tab Colors"); private static final WidgetPropertyDescriptor propDeselectedColor = CommonWidgetProperties.newColorPropertyDescriptor(WidgetPropertyCategory.DISPLAY, "deselected_color", "Deselected Color"); 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!")); From 1ccd11ebb90137e230fe040e92e8ab102c3c7ff3 Mon Sep 17 00:00:00 2001 From: shroffk Date: Fri, 9 Jan 2026 15:30:44 -0500 Subject: [PATCH 81/83] Create a preference to enable/disable add PVs to alarm configuration context menu action --- .../java/org/phoebus/applications/alarm/AlarmSystem.java | 3 +++ .../model/src/main/resources/alarm_preferences.properties | 7 +++++++ .../alarm/ui/tree/ContextMenuAddComponentPVs.java | 5 +++++ 3 files changed, 15 insertions(+) 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/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/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 index 7c6b6af104..ee49043ec0 100644 --- 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 @@ -31,6 +31,11 @@ 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"; From 72630a2bcc810c53cae39989383587002197f459 Mon Sep 17 00:00:00 2001 From: lcaouen Date: Mon, 12 Jan 2026 17:03:05 +0100 Subject: [PATCH 82/83] Fix https://github.com/ControlSystemStudio/phoebus/issues/3685 --- .../framework/preferences/PropertyPreferenceWriter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java b/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java index cc23f60f36..cde858a98a 100644 --- a/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java +++ b/core/framework/src/main/java/org/phoebus/framework/preferences/PropertyPreferenceWriter.java @@ -59,11 +59,11 @@ public static void save(final OutputStream stream) throws Exception String value = prefs.get("excluded_keys_from_settings_check", ""); if (value.isEmpty()) value = allKeysWithPackages.get("org.phoebus.ui/excluded_keys_from_settings_check"); - if (!value.isEmpty()) excludedKeys = Arrays.stream(value.split(",")).map(String::trim).collect(Collectors.toSet()); + if (value != null && !value.isEmpty()) excludedKeys = Arrays.stream(value.split(",")).map(String::trim).collect(Collectors.toSet()); value = prefs.get("excluded_packages_from_settings_check", ""); if (value.isEmpty()) value = allKeysWithPackages.get("org.phoebus.ui/excluded_packages_from_settings_check"); - if (!value.isEmpty()) excludedPackages = Arrays.stream(value.split(",")).map(String::trim).collect(Collectors.toSet()); + if (value != null && !value.isEmpty()) excludedPackages = Arrays.stream(value.split(",")).map(String::trim).collect(Collectors.toSet()); try ( From 75b01dd48fc809daf55e285e1e807818dba55d44 Mon Sep 17 00:00:00 2001 From: Abraham Wolk Date: Thu, 22 Jan 2026 10:15:13 +0100 Subject: [PATCH 83/83] CSSTUDIO-3620 Bugfix: When no range is specified, the default range [0.0, 100.0] should only be set if there are no observed values to base the range on. --- .../widgets/linearmeter/LinearMeterRepresentation.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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; + } } } }