diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/Messages.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/Messages.java index 13272352db..e8ebd79875 100644 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/Messages.java +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/Messages.java @@ -43,6 +43,8 @@ public class Messages public static String promptTitle; public static String removeComponentFailed; public static String renameItemFailed; + public static String search; + public static String searchByPv; public static String totalPVs; public static String unacknowledgeFailed; public static String withEnableDate; diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/area/AlarmAreaView.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/area/AlarmAreaView.java index 63f5762453..809ad3d2e1 100644 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/area/AlarmAreaView.java +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/area/AlarmAreaView.java @@ -75,8 +75,11 @@ public class AlarmAreaView extends StackPane implements AlarmClientListener */ private final AtomicReference> item_changes = new AtomicReference<>(); - /** Set of items that have updated, with iteration order based on when they updated */ - private final Set items_to_update = new LinkedHashSet<>(); + /** Set of items that have updated, with iteration order based on when they updated + * + * SYNC on access + */ + private final Set item_paths_to_update = new LinkedHashSet<>(); /** Throttle [5Hz] used for updates of existing items */ private final UpdateThrottle update_throttle = new UpdateThrottle(200, TimeUnit.MILLISECONDS, this::updateItems); @@ -165,9 +168,9 @@ public void itemUpdated(final AlarmTreeItem item) if (null == item_name) return; - synchronized (items_to_update) + synchronized (item_paths_to_update) { - items_to_update.add(item_name); + item_paths_to_update.add(item_name); } update_throttle.trigger(); } @@ -178,10 +181,10 @@ private void updateItems() final List changed_items = item_changes.getAndSet(null); final String[] update_array; - synchronized (items_to_update) + synchronized (item_paths_to_update) { - update_array = items_to_update.toArray(new String[items_to_update.size()]); - items_to_update.clear(); + update_array = item_paths_to_update.toArray(new String[item_paths_to_update.size()]); + item_paths_to_update.clear(); } Platform.runLater(() -> { 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 f3d65c9801..0025cd5954 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 @@ -12,10 +12,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -24,6 +25,7 @@ import java.util.stream.Collectors; import javafx.collections.FXCollections; +import javafx.scene.control.TextField; import org.phoebus.applications.alarm.AlarmSystem; import org.phoebus.applications.alarm.client.AlarmClient; import org.phoebus.applications.alarm.client.AlarmClientLeaf; @@ -33,6 +35,7 @@ import org.phoebus.applications.alarm.model.BasicState; import org.phoebus.applications.alarm.ui.AlarmContextMenuHelper; import org.phoebus.applications.alarm.ui.AlarmUI; +import org.phoebus.applications.alarm.ui.Messages; import org.phoebus.applications.alarm.ui.config.ItemConfigDialog; import org.phoebus.framework.selection.Selection; import org.phoebus.framework.selection.SelectionService; @@ -40,6 +43,7 @@ import org.phoebus.ui.application.SaveSnapshotAction; import org.phoebus.ui.dialog.DialogHelper; import org.phoebus.ui.docking.DockPane; +import org.phoebus.ui.javafx.ClearingTextField; import org.phoebus.ui.javafx.ImageCache; import org.phoebus.ui.javafx.PrintAction; import org.phoebus.ui.javafx.Screenshot; @@ -80,6 +84,14 @@ * as generic JavaFX Node, only calling public methods * defined on this class. * + *

The model (AlarmClient) and the AlarmTreeView are kept in + * sync by a lockstep between model and view updates. When the + * model is changed, it calls item[removed|added]. The AlarmClient + * then blocks until the method returns (i.e. until the UI has + * been updated) and then can move on to the next update. The + * exception to this is itemUpdated, which adds to a queue of + * items to be updated which is then regularly processed. + * * @author Kay Kasemir */ @SuppressWarnings("nls") @@ -112,13 +124,13 @@ public class AlarmTreeView extends BorderPane implements AlarmClientListener private final CountDownLatch block_item_changes = new CountDownLatch(1); /** Map from alarm tree path to view's TreeItem */ - private final ConcurrentHashMap>> path2view = new ConcurrentHashMap<>(); + private final Map>> path2view = new HashMap<>(); /** Items to update, ordered by time of original update request * - * SYNC on access + * SYNC on access */ - private final Set>> items_to_update = new LinkedHashSet<>(); + private final Set item_paths_to_update = new LinkedHashSet<>(); /** Throttle [5Hz] used for updates of existing items */ private final UpdateThrottle throttle = new UpdateThrottle(200, TimeUnit.MILLISECONDS, this::performUpdates); @@ -140,6 +152,8 @@ public class AlarmTreeView extends BorderPane implements AlarmClientListener items.remove(changing); }); + private final TextField searchBox; + // Javadoc for TreeItem shows example for overriding isLeaf() and getChildren() // to dynamically create TreeItem as TreeView requests information. // @@ -161,7 +175,7 @@ public AlarmTreeView(final AlarmClient model) { * @param model Model to represent. Must not be running, yet * @param itemName item name that may be expanded or given focus */ - public AlarmTreeView(final AlarmClient model, String itemName) + public AlarmTreeView(final AlarmClient model, final String itemName) { if (model.isRunning()) throw new IllegalStateException(); @@ -176,7 +190,11 @@ public AlarmTreeView(final AlarmClient model, String itemName) tree_view.setCellFactory(view -> new AlarmTreeViewCell()); tree_view.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); - setTop(createToolbar()); + searchBox = new ClearingTextField(); + searchBox.setPromptText(Messages.searchByPv); + searchBox.setPrefWidth(300); + + setTop(createToolbar(searchBox)); setCenter(tree_view); createContextMenu(); @@ -234,7 +252,7 @@ private void startup() }); } - private ToolBar createToolbar() + private ToolBar createToolbar(TextField searchBox) { final Button collapse = new Button("", ImageCache.getImageView(AlarmUI.class, "/icons/collapse.png")); @@ -255,7 +273,20 @@ private ToolBar createToolbar() show_disabled.setTooltip(new Tooltip("Expand alarm tree to show disabled PVs")); show_disabled.setOnAction(event -> expandDisabledPVs(tree_view.getRoot())); - return new ToolBar(no_server, changing, ToolbarHelper.createSpring(), collapse, show_alarms, show_disabled); + final Button searchButton = new Button(Messages.search); + + searchButton.setOnAction(event -> propagateVisibilityDownTree(tree_view.getRoot())); + searchBox.setOnAction(event -> propagateVisibilityDownTree(tree_view.getRoot())); // act as if button pressed when enter pressed + searchBox.textProperty().addListener(_ -> + { + // act as if button pressed when text box cleared + if (searchBox.getText().isEmpty()) + { + propagateVisibilityDownTree(tree_view.getRoot()); + } + }); + + return new ToolBar(searchBox, searchButton, no_server, changing, ToolbarHelper.createSpring(), collapse, show_alarms, show_disabled); } ToolBar getToolbar() @@ -263,6 +294,96 @@ ToolBar getToolbar() return (ToolBar) getTop(); } + /** Recursively updates the tree so that it only shows the items matching the search. + * + * This is done bottom-up, by recursing down to the leaf nodes initially to determine + * whether they should be visible, and then returning up the tree to determine whether + * each component should be visible or not, and showing/hiding it accordingly. + */ + private boolean propagateVisibilityDownTree(final TreeItem> item) + { + for (final AlarmTreeItem child : item.getValue().getChildren()) + { + final TreeItem> childItem = path2view.get(child.getPathName()); + + final boolean shouldDisplay = propagateVisibilityDownTree(childItem); + + if (shouldDisplay) + { + final boolean doesNotHaveChildAlready = item.getChildren().stream() + .noneMatch(viewChild -> viewChild.getValue().getPathName().equals(child.getPathName())); + if (doesNotHaveChildAlready) + // should display and does not have child, so add child + addChildToParent(item, childItem); + } + else + item.getChildren().removeIf( + alarmTreeItemTreeItem -> alarmTreeItemTreeItem.getValue() == child + ); + } + + return shouldDisplay(item); + } + + /** Ensures that an item is shown if it matches the search term, and not shown if it does not. + * + * This is done by determining whether the current node should be visible, and then propagating + * up the tree to ensure all the parent nodes are also only visible if they match the search term. + * This does not consider child nodes; it's intended to be called when a single node is added or + * updated to minimise state updates. + */ + private void propagateVisibilityUpTree(final TreeItem> item) + { + final AlarmTreeItem modelParent = item.getValue().getParent(); + if (modelParent == null) + { + // root + return; + } + + final TreeItem> currentParent = item.getParent(); + final TreeItem> actualParent = path2view.get(modelParent.getPathName()); + + final boolean shouldDisplay = shouldDisplay(item); + if (shouldDisplay && currentParent == null) + { + // should display but does not have a parent (so will not be displayed) + addChildToParent(actualParent, item); + propagateVisibilityUpTree(actualParent); + } + else if (!shouldDisplay && currentParent != null) + { + // should not display but has a parent (so will be displayed) + actualParent.getChildren().remove(item); + propagateVisibilityUpTree(actualParent); + } + } + + private boolean shouldDisplay(final TreeItem> item) + { + return (searchBox.getText().isEmpty() || !item.getChildren().isEmpty()) + || item.getValue() instanceof AlarmClientLeaf && item.getValue().getName().toLowerCase().contains(searchBox.getText().toLowerCase()); + } + + /** + * Adds a child TreeItem to a parent TreeItem, ensuring the order is preserved + * + * @param view_parent + * @param view_item + */ + private static void addChildToParent(TreeItem> view_parent, TreeItem> view_item) + { + // 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); + } + private void expandAlarms(final TreeItem> node) { if (node.isLeaf()) @@ -331,7 +452,7 @@ private void indicateChange() setCursor(Cursor.WAIT); final ObservableList items = getToolbar().getItems(); if (! items.contains(changing)) - items.add(1, changing); + items.add(3, changing); } else previous.cancel(false); @@ -387,37 +508,26 @@ private void blockItemChanges() 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_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); + + // 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_view.getRoot()); + throw new IllegalStateException("Missing parent view item for " + item.getPathName()); + } + final TreeItem> view_item = createViewItem(item); + + propagateVisibilityUpTree(view_item); done.countDown(); }); - updateStats(); // Waiting on the UI thread throttles the model's updates // to a rate that the UI can handle. @@ -438,28 +548,30 @@ public void itemAdded(final AlarmTreeItem item) 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(); + + // 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 + // // 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); + if (view_parent != null) + { + view_parent.getChildren().remove(view_item); + propagateVisibilityUpTree(view_parent); + } done.countDown(); }); - updateStats(); // Waiting on the UI thread throttles the model's updates // to a rate that the UI can handle. @@ -493,87 +605,85 @@ private TreeItem> removeViewItems(final AlarmTreeItem item) 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) + // We push the path instead of the view item in case the view item is removed + // between now and the update being handled. + synchronized (item_paths_to_update) { - items_to_update.add(view_item); + item_paths_to_update.add(item.getPathName()); } throttle.trigger(); - updateStats(); } /** Called by throttle to perform accumulated updates */ - @SuppressWarnings("unchecked") private void performUpdates() { - final TreeItem>[] view_items; - synchronized (items_to_update) + + // Drain items to update in one go; we will process it later on the main thread + final String[] view_item_paths; + synchronized (item_paths_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(); + view_item_paths = item_paths_to_update.toArray(new String[item_paths_to_update.size()]); + item_paths_to_update.clear(); } - // Remember selection - final ObservableList>> updatedSelectedItems = - FXCollections.observableArrayList(tree_view.getSelectionModel().getSelectedItems()); - - // How to update alarm tree cells when data changed? - // `setValue()` with a truly new value (not 'equal') should suffice, - // but there are two problems: - // 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); + Platform.runLater(() -> { + // Remember selection + final ObservableList>> updatedSelectedItems = + FXCollections.observableArrayList(tree_view.getSelectionModel().getSelectedItems()); + + // How to update alarm tree cells when data changed? + // `setValue()` with a truly new value (not 'equal') should suffice, + // but there are two problems: + // 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 String view_item_path : view_item_paths) { + final TreeItem> view_item = path2view.get(view_item_path); + if (view_item == null) { + // Possible if the item has been removed since it was added to the update queue + continue; + } + + // 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); } - // 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_view.getSelectionModel().clearSelection(); - updatedSelectedItems.forEach(item -> tree_view.getSelectionModel().select(item)); + + // Restore selection + tree_view.getSelectionModel().clearSelection(); + updatedSelectedItems.forEach(item -> tree_view.getSelectionModel().select(item)); + }); } /** Context menu, details depend on selected items */ @@ -689,25 +799,7 @@ private void addDragSupport() }); } -// 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) + private static void dumpTree(final TreeItem> item) { final ObservableList>> children = item.getChildren(); System.out.printf("item: %s , has %d children.\n", item.getValue().getName(), children.size()); diff --git a/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/messages.properties b/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/messages.properties index 83b48427d4..e083056f7d 100644 --- a/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/messages.properties +++ b/app/alarm/ui/src/main/resources/org/phoebus/applications/alarm/ui/messages.properties @@ -81,5 +81,7 @@ tooltipMoveEditDetail=Edit the detail field of table item tooltipMoveTableItemDown=Move table item down tooltipMoveTableItemUp=Move table item up totalPVs=PVs: {0} +search=Search +searchByPv=Search by PV unacknowledgeFailed=Failed to unacknowledge alarm(s) withEnableDate=With Enable Date