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 d25333644e..c290bb344c 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 @@ -121,7 +121,6 @@ public class DisplayEditorInstance implements AppInstance menu_node.setOnContextMenuRequested(event -> handleContextMenu(menu, event)); menu_node.setContextMenu(menu); - dock_item.addCloseCheck(this::okToClose); dock_item.addClosedNotification(this::dispose); } @@ -465,7 +464,7 @@ void doSave(final JobMonitor monitor) throws Exception else { // Save-As with proper file name dock_item.setInput(proper.toURI()); - if (! dock_item.save_as(monitor)) + if (! dock_item.save_as(monitor, dock_item.getTabPane().getScene().getWindow())) dock_item.setInput(orig_input); } } diff --git a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DisplayRuntimeInstance.java b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DisplayRuntimeInstance.java index dcf60af364..46e145c5bf 100644 --- a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DisplayRuntimeInstance.java +++ b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DisplayRuntimeInstance.java @@ -335,15 +335,18 @@ public void loadDisplayFile(final DisplayInfo info) display_info = Optional.empty(); - if (dock_item.prepareToClose()) - Platform.runLater(() -> - { - final Parent parent = representation.getModelParent(); - JFXRepresentation.getChildren(parent).clear(); - - close(); - }); - return; + boolean shouldClose = dock_item.okToClose().get(); + + if (shouldClose) { + dock_item.prepareToClose(); + Platform.runLater(() -> + { + final Parent parent = representation.getModelParent(); + JFXRepresentation.getChildren(parent).clear(); + + close(); + }); + } } }); } diff --git a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DockItemRepresentation.java b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DockItemRepresentation.java index 5a13862a88..fc23c0dce4 100644 --- a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DockItemRepresentation.java +++ b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/DockItemRepresentation.java @@ -141,24 +141,4 @@ public void representModel(final Parent model_parent, final DisplayModel model) app_instance.trackCurrentModel(model); super.representModel(model_parent, model); } - - @Override - public void closeWindow(final DisplayModel model) throws Exception - { - // Is called from ScriptUtil, i.e. scripts, from background thread - final Parent model_parent = Objects.requireNonNull(model.getUserData(Widget.USER_DATA_TOOLKIT_PARENT)); - if (model_parent.getProperties().get(DisplayRuntimeInstance.MODEL_PARENT_DISPLAY_RUNTIME) == app_instance) - { - // Prepare-to-close, which might take time and must be called off the UI thread - final DisplayRuntimeInstance instance = (DisplayRuntimeInstance) app_instance.getRepresentation().getModelParent().getProperties().get(DisplayRuntimeInstance.MODEL_PARENT_DISPLAY_RUNTIME); - if (instance != null) - instance.getDockItem().prepareToClose(); - else - logger.log(Level.SEVERE, "Missing DisplayRuntimeInstance to prepare closing", new Exception("Stack Trace")); - // 'close' on the UI thread - execute(() -> app_instance.close()); - } - else - throw new Exception("Wrong model"); - } } diff --git a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/script/ScriptUtil.java b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/script/ScriptUtil.java index 7f6b4b2a60..600fff19bd 100644 --- a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/script/ScriptUtil.java +++ b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/script/ScriptUtil.java @@ -141,24 +141,6 @@ public static void openDisplay(final Widget widget, final String file, final Str ActionUtil.handleAction(widget, open); } - /** Close a display - * - * @param widget Widget within the display to close - */ - public static void closeDisplay(final Widget widget) - { - try - { - final DisplayModel model = widget.getTopDisplayModel(); - final ToolkitRepresentation toolkit = ToolkitRepresentation.getToolkit(model); - toolkit.closeWindow(model); - } - catch (Throwable ex) - { - logger.log(Level.WARNING, "Cannot close display", ex); - } - } - // ==================== // public alert dialog utils diff --git a/core/ui/src/main/java/org/phoebus/ui/application/Messages.java b/core/ui/src/main/java/org/phoebus/ui/application/Messages.java index 8708f628ea..9f01d78745 100644 --- a/core/ui/src/main/java/org/phoebus/ui/application/Messages.java +++ b/core/ui/src/main/java/org/phoebus/ui/application/Messages.java @@ -146,6 +146,26 @@ public class Messages public static String TimeYear; public static String TopResources; public static String UnLockPane; + public static String UnsavedChanges; + public static String UnsavedChanges_clearButtonText; + public static String UnsavedChanges_close; + public static String UnsavedChanges_discardButtonText_discardAnd; + public static String UnsavedChanges_exit; + public static String UnsavedChanges_mainWindow; + public static String UnsavedChanges_replace; + public static String UnsavedChanges_saveButtonText; + public static String UnsavedChanges_saveButtonText_saveAnd; + public static String UnsavedChanges_saved; + public static String UnsavedChanges_saving; + public static String UnsavedChanges_savingFailed; + public static String UnsavedChanges_secondaryWindow; + public static String UnsavedChanges_selectAllButtonText; + public static String UnsavedChanges_theFollowingApplicationInstancesHaveUnsavedChanges; + public static String UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeClosingAllTabs; + public static String UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeClosingTheTabs; + public static String UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeClosingTheWindow; + public static String UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeExiting; + public static String UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeReplacingTheLayout; public static String WebBrowser; public static String Welcome; public static String Window; diff --git a/core/ui/src/main/java/org/phoebus/ui/application/PhoebusApplication.java b/core/ui/src/main/java/org/phoebus/ui/application/PhoebusApplication.java index ed20e97dca..ec1a7c13f4 100644 --- a/core/ui/src/main/java/org/phoebus/ui/application/PhoebusApplication.java +++ b/core/ui/src/main/java/org/phoebus/ui/application/PhoebusApplication.java @@ -5,14 +5,48 @@ import java.io.FileNotFoundException; import java.lang.ref.WeakReference; import java.net.URI; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.SortedMap; +import java.util.TreeMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Supplier; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; +import javafx.event.ActionEvent; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckBox; +import javafx.scene.control.CheckMenuItem; +import javafx.scene.control.Dialog; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuButton; +import javafx.scene.control.MenuItem; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.SeparatorMenuItem; +import javafx.scene.control.ToolBar; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; +import javafx.scene.text.Text; import org.phoebus.framework.jobs.JobManager; import org.phoebus.framework.jobs.JobMonitor; import org.phoebus.framework.jobs.SubJobMonitor; @@ -48,27 +82,13 @@ import javafx.application.Application; import javafx.application.Platform; import javafx.scene.Node; -import javafx.scene.control.Alert; import javafx.scene.control.Alert.AlertType; -import javafx.scene.control.Button; -import javafx.scene.control.ButtonType; -import javafx.scene.control.CheckMenuItem; -import javafx.scene.control.Dialog; -import javafx.scene.control.Menu; -import javafx.scene.control.MenuBar; -import javafx.scene.control.MenuButton; -import javafx.scene.control.MenuItem; -import javafx.scene.control.SeparatorMenuItem; -import javafx.scene.control.ToolBar; -import javafx.scene.control.Tooltip; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; import javafx.scene.input.MouseEvent; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.VBox; import javafx.stage.Stage; import javafx.stage.Window; @@ -477,10 +497,10 @@ private MenuBar createMenu(final Stage stage) { top_resources_menu.setDisable(true); final MenuItem file_save = new MenuItem(Messages.Save, ImageCache.getImageView(getClass(), "/icons/save_edit.png")); - file_save.setOnAction(event -> JobManager.schedule(Messages.Save, monitor -> active_item_with_input.get().save(monitor))); + file_save.setOnAction(event -> JobManager.schedule(Messages.Save, monitor -> active_item_with_input.get().save(monitor, active_item_with_input.get().getTabPane().getScene().getWindow()))); final MenuItem file_save_as = new MenuItem(Messages.SaveAs, ImageCache.getImageView(getClass(), "/icons/saveas_edit.png")); - file_save_as.setOnAction(event -> JobManager.schedule(Messages.SaveAs, monitor -> active_item_with_input.get().save_as(monitor))); + file_save_as.setOnAction(event -> JobManager.schedule(Messages.SaveAs, monitor -> active_item_with_input.get().save_as(monitor, active_item_with_input.get().getTabPane().getScene().getWindow()))); final MenuItem exit = new MenuItem(Messages.Exit); exit.setOnAction(event -> closeMainStage()); @@ -1054,31 +1074,41 @@ private void replaceLayout(final MementoTree memento) { JobManager.schedule("Close all stages", monitor -> { - for (Stage stage : stages) - if (!DockStage.prepareToCloseItems(stage)) - return; + boolean shouldReplaceLayout = confirmationDialogWhenUnsavedChangesExist(stages, + Messages.UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeReplacingTheLayout, + Messages.UnsavedChanges_replace, + main_stage, + monitor); - // All stages OK to close - Platform.runLater(() -> - { + if (shouldReplaceLayout) { for (Stage stage : stages) { - DockStage.closeItems(stage); - // Don't wait for Platform.runLater-based tab handlers - // that will merge splits and eventually close the empty panes, - // but close all non-main stages right away - if (stage != main_stage) - stage.close(); + if (!DockStage.prepareToCloseItems(stage)) { + return; + } } - // Go into the main stage and close all of the tabs. If any of them refuse, return. - final Node node = DockStage.getPaneOrSplit(main_stage); - if (!MementoHelper.closePaneOrSplit(node)) - return; + // All stages OK to close + Platform.runLater(() -> + { + for (Stage stage : stages) { + DockStage.closeItems(stage); + // Don't wait for Platform.runLater-based tab handlers + // that will merge splits and eventually close the empty panes, + // but close all non-main stages right away + if (stage != main_stage) + stage.close(); + } - // Allow handlers for tab changes etc. to run as everything closed. - // On next UI tick, load content from memento file. - Platform.runLater(() -> restoreState(memento)); - }); + // Go into the main stage and close all of the tabs. If any of them refuse, return. + final Node node = DockStage.getPaneOrSplit(main_stage); + if (!MementoHelper.closePaneOrSplit(node)) + return; + + // Allow handlers for tab changes etc. to run as everything closed. + // On next UI tick, load content from memento file. + Platform.runLater(() -> restoreState(memento)); + }); + } }); } @@ -1263,22 +1293,347 @@ private void closeMainStage() { JobManager.schedule("Close all stages", monitor -> { - // closeStages() - for (Stage stage : stages) - if (!DockStage.prepareToCloseItems(stage)) - return; + boolean shouldExit = confirmationDialogWhenUnsavedChangesExist(stages, + Messages.UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeExiting, + Messages.UnsavedChanges_exit, + main_stage, + monitor); - // All stages OK to close - Platform.runLater(() -> - { + if (shouldExit) { for (Stage stage : stages) - DockStage.closeItems(stage); + if (!DockStage.prepareToCloseItems(stage)) { + return; + } - stop(); - }); + exitPhoebus(); + } }); } + private static SortedMap> listOfDockItems2ApplicationNameToDockItemsWithInput(List dockItems) { + SortedMap> applicationNameToDockItemsWithInput = new TreeMap<>(); + for (DockItem dockItem : dockItems) { + if (dockItem instanceof DockItemWithInput) { + DockItemWithInput dockItemWithInput = (DockItemWithInput) dockItem; + if (dockItemWithInput.isDirty()) { + String applicationName = dockItemWithInput.getApplication().getAppDescriptor().getDisplayName(); + if (!applicationNameToDockItemsWithInput.containsKey(applicationName)) { + applicationNameToDockItemsWithInput.put(applicationName, new LinkedList<>()); + } + + applicationNameToDockItemsWithInput.get(applicationName).add(dockItemWithInput); + } + } + } + return applicationNameToDockItemsWithInput; + } + + private static SortedMap>> stages2WindowNameToApplicationNameToDockItemsWithInput(List stages) { + SortedMap>> windowNameToApplicationNameToDockItemsWithInput = new TreeMap<>(); + { + int currentWindowNr = 1; + + for (Stage stage : stages) { + String currentWindowName; + if (stage == DockStage.getDockStages().get(0)) { + currentWindowName = Messages.UnsavedChanges_mainWindow; + } + else { + currentWindowName = Messages.UnsavedChanges_secondaryWindow + " " + currentWindowNr; + currentWindowNr++; + } + + List dockItems = DockStage.getDockPanes(stage).stream().flatMap(dockPane -> dockPane.getDockItems().stream()).collect(Collectors.toList()); + SortedMap> applicationNameToDockItemsWithInput = PhoebusApplication.listOfDockItems2ApplicationNameToDockItemsWithInput(dockItems); + windowNameToApplicationNameToDockItemsWithInput.put(currentWindowName, applicationNameToDockItemsWithInput); + } + } + return windowNameToApplicationNameToDockItemsWithInput; + } + + public static boolean confirmationDialogWhenUnsavedChangesExist(Stage stage, + String question, + String closeActionName, + JobMonitor monitor) throws ExecutionException, InterruptedException { + List dockItems = DockStage.getDockPanes(stage).stream().flatMap(dockPane -> dockPane.getDockItems().stream()).collect(Collectors.toList()); + SortedMap> applicationNameToDockItemsWithInput = PhoebusApplication.listOfDockItems2ApplicationNameToDockItemsWithInput(dockItems); + SortedMap>> windowNameToApplicationNameToDockItemsWithInput = new TreeMap<>(); + windowNameToApplicationNameToDockItemsWithInput.put("Window", applicationNameToDockItemsWithInput); + + return confirmationDialogWhenUnsavedChangesExist(windowNameToApplicationNameToDockItemsWithInput, + question, + closeActionName, + stage, + monitor); + } + + public static boolean confirmationDialogWhenUnsavedChangesExist(ArrayList dockItems, // "ArrayList" is used instead of "List" to prevent a conflict with "List" after type erasure. + String question, + String closeActionName, + Stage stage, + JobMonitor monitor) throws ExecutionException, InterruptedException { + SortedMap> applicationNameToDockItemsWithInput = PhoebusApplication.listOfDockItems2ApplicationNameToDockItemsWithInput(dockItems); + SortedMap>> windowNameToApplicationNameToDockItemsWithInput = new TreeMap<>(); + windowNameToApplicationNameToDockItemsWithInput.put("Window", applicationNameToDockItemsWithInput); + + return confirmationDialogWhenUnsavedChangesExist(windowNameToApplicationNameToDockItemsWithInput, + question, + closeActionName, + stage, + monitor); + } + + public static boolean confirmationDialogWhenUnsavedChangesExist(List stages, + String question, + String closeActionName, + Stage stage, + JobMonitor monitor) throws ExecutionException, InterruptedException { + return confirmationDialogWhenUnsavedChangesExist(stages2WindowNameToApplicationNameToDockItemsWithInput(stages), + question, + closeActionName, + stage, + monitor); + } + + private enum SaveStatus { + SUCCESS, + FAILURE, + NOTHING + }; + + public static boolean confirmationDialogWhenUnsavedChangesExist(SortedMap>> windowNrToApplicationNameToDockItemsWithInput, + String question, + String closeActionName, + Stage stage, + JobMonitor monitor) throws ExecutionException, InterruptedException { + + Stage stageToPositionTheConfirmationDialogOver; + if (stage != null) { + stageToPositionTheConfirmationDialogOver = stage; + } + else { + stageToPositionTheConfirmationDialogOver = INSTANCE.main_stage; + } + + ButtonType clearSelectionOfCheckboxes = new ButtonType(Messages.UnsavedChanges_clearButtonText); + ButtonType selectAllCheckboxes = new ButtonType(Messages.UnsavedChanges_selectAllButtonText); + ButtonType saveSelectedItems = new ButtonType(Messages.UnsavedChanges_saveButtonText); + ButtonType exitPhoebusWithoutSavingUnsavedChanges = new ButtonType(Messages.UnsavedChanges_discardButtonText_discardAnd + " " + closeActionName); + + FutureTask displayConfirmationWindow = new FutureTask(() -> { + Alert prompt = new Alert(AlertType.CONFIRMATION); + + prompt.getDialogPane().getButtonTypes().remove(ButtonType.OK); + ((ButtonBar) prompt.getDialogPane().lookup(".button-bar")).setButtonOrder(ButtonBar.BUTTON_ORDER_NONE); // Set the button order manually (since they are non-standard) + prompt.getDialogPane().getButtonTypes().add(clearSelectionOfCheckboxes); + prompt.getDialogPane().getButtonTypes().add(selectAllCheckboxes); + prompt.getDialogPane().getButtonTypes().add(saveSelectedItems); + prompt.getDialogPane().getButtonTypes().add(exitPhoebusWithoutSavingUnsavedChanges); + + Button cancel_button = (Button) prompt.getDialogPane().lookupButton(ButtonType.CANCEL); + cancel_button.setTooltip(new Tooltip(cancel_button.getText())); + + Button clearSelectionOfCheckboxes_button = (Button) prompt.getDialogPane().lookupButton(clearSelectionOfCheckboxes); + clearSelectionOfCheckboxes_button.setTooltip(new Tooltip(clearSelectionOfCheckboxes_button.getText())); + + Button selectAllCheckboxes_button = (Button) prompt.getDialogPane().lookupButton(selectAllCheckboxes); + selectAllCheckboxes_button.setTooltip(new Tooltip(selectAllCheckboxes_button.getText())); + + Button saveSelectedItems_button = (Button) prompt.getDialogPane().lookupButton(saveSelectedItems); + saveSelectedItems_button.setTooltip(new Tooltip(saveSelectedItems_button.getText())); + + Button exitPhoebusWithoutSavingUnsavedChanges_button = (Button) prompt.getDialogPane().lookupButton(exitPhoebusWithoutSavingUnsavedChanges); + exitPhoebusWithoutSavingUnsavedChanges_button.setTooltip(new Tooltip(exitPhoebusWithoutSavingUnsavedChanges_button.getText())); + List> setCheckBoxStatusActions = new LinkedList<>(); + List> getCheckBoxStatusActions = new LinkedList<>(); + List> saveActions = new LinkedList<>(); + + Runnable enableAndDisableButtons = () -> { + if (getCheckBoxStatusActions.stream().anyMatch(getCheckBoxStatus -> getCheckBoxStatus.get())) { + clearSelectionOfCheckboxes_button.setDisable(false); + saveSelectedItems_button.setDisable(false); + exitPhoebusWithoutSavingUnsavedChanges_button.setDisable(true); + } + else { + clearSelectionOfCheckboxes_button.setDisable(true); + saveSelectedItems_button.setDisable(true); + exitPhoebusWithoutSavingUnsavedChanges_button.setDisable(false); + } + + if (getCheckBoxStatusActions.stream().allMatch(getCheckBoxStatus -> getCheckBoxStatus.get())) { + selectAllCheckboxes_button.setDisable(true); + saveSelectedItems_button.setText(Messages.UnsavedChanges_saveButtonText_saveAnd + " " + closeActionName); + saveSelectedItems_button.setTooltip(new Tooltip(saveSelectedItems_button.getText())); + } + else { + selectAllCheckboxes_button.setDisable(false); + saveSelectedItems_button.setText(Messages.UnsavedChanges_saveButtonText); + saveSelectedItems_button.setTooltip(new Tooltip(saveSelectedItems_button.getText())); + } + }; + + GridPane gridPane = new GridPane(); + gridPane.setVgap(4); + int currentRow = 0; + for (String windowName : windowNrToApplicationNameToDockItemsWithInput.keySet()) { + var applicationNameToDockItemsWithInput = windowNrToApplicationNameToDockItemsWithInput.get(windowName); + + if (applicationNameToDockItemsWithInput.size() > 0) { // Only print unsaved changes for a window if it actually containts any unsaved changes. + if (windowNrToApplicationNameToDockItemsWithInput.size() >= 2) { // Only print the window names if two or more windows are in the process of being closed. + Text windowTitle = new Text(windowName); + windowTitle.setStyle("-fx-font-size: 16; -fx-font-weight: bold"); + gridPane.add(windowTitle, 0, currentRow); + currentRow++; + } + + for (var applicationName : applicationNameToDockItemsWithInput.keySet()) { + for (var dockItemWithInput : applicationNameToDockItemsWithInput.get(applicationName)) { + CheckBox checkBox = new CheckBox(); + checkBox.selectedProperty().addListener((observableValue, old_value, new_value) -> enableAndDisableButtons.run()); + + Text applicationName_text = new Text(applicationName + ":"); + applicationName_text.setStyle("-fx-font-weight: bold"); + Text instanceName_text = new Text(dockItemWithInput.getLabel()); + + HBox hBox = new HBox(checkBox, applicationName_text, instanceName_text); + hBox.setSpacing(4); + gridPane.add(hBox, 0, currentRow); + + Consumer setCheckboxStatus = bool -> checkBox.setSelected(bool); + setCheckBoxStatusActions.add(setCheckboxStatus); + + Supplier getCheckBoxStatus = () -> checkBox.isSelected(); + getCheckBoxStatusActions.add(getCheckBoxStatus); + + hBox.addEventHandler(MouseEvent.MOUSE_CLICKED, mouseEvent -> checkBox.setSelected(!checkBox.isSelected())); // Enable toggling checkbox by clicking on its label. + + Supplier saveIfCheckboxEnabled = () -> { + if (checkBox.isSelected()) { + + Text saving = new Text("[" + Messages.UnsavedChanges_saving + "]"); + saving.setFill(Color.ORANGE); + saving.setStyle("-fx-font-weight: bold;"); + hBox.getChildren().set(0, saving); + boolean saveSuccessful = dockItemWithInput.save(monitor, prompt.getDialogPane().getScene().getWindow()); + + if (saveSuccessful) { + // The functions setCheckboxStatus() and getCheckBoxStatus should not be available anymore: + setCheckBoxStatusActions.remove(setCheckboxStatus); + getCheckBoxStatusActions.remove(getCheckBoxStatus); + setCheckboxStatus.accept(false); + + Text saved = new Text("[" + Messages.UnsavedChanges_saved + "]"); + saved.setFill(Color.GREEN); + saved.setStyle("-fx-font-weight: bold;"); + hBox.getChildren().set(0, saved); + return SaveStatus.SUCCESS; + } + else { + Text savingFailed_text = new Text("[" + Messages.UnsavedChanges_savingFailed + "]"); + savingFailed_text.setFill(Color.RED); + savingFailed_text.setStyle("-fx-font-weight: bold;"); + + HBox savingFailed = new HBox(checkBox, savingFailed_text); + savingFailed.setSpacing(6); + hBox.getChildren().set(0, savingFailed); + return SaveStatus.FAILURE; + } + } + else { + return SaveStatus.NOTHING; + } + }; + saveActions.add(saveIfCheckboxEnabled); + + currentRow++; + } + } + } + } + + ScrollPane scrollPane = new ScrollPane(); + scrollPane.setContent(gridPane); + + prompt.getDialogPane().setContent(scrollPane); + + clearSelectionOfCheckboxes_button.addEventFilter(ActionEvent.ACTION, event -> { + event.consume(); + + setCheckBoxStatusActions.forEach(setCheckboxAction -> { + setCheckboxAction.accept(false); + }); + }); + + selectAllCheckboxes_button.addEventFilter(ActionEvent.ACTION, event -> { + event.consume(); + + setCheckBoxStatusActions.forEach(setCheckboxAction -> { + setCheckboxAction.accept(true); + }); + }); + + saveSelectedItems_button.addEventFilter(ActionEvent.ACTION, event -> { + event.consume(); + + List> saveActionsThatHaveBeenCompleted = new LinkedList<>(); + for (var saveAction : saveActions) { + SaveStatus result = saveAction.get(); + if (result == SaveStatus.SUCCESS) { + saveActionsThatHaveBeenCompleted.add(saveAction); + } + else if (result == SaveStatus.FAILURE) { + break; + } + // If result == SaveStatus.NOTHING, continue. + } + + for (var saveActionThatHasBeenCompleted : saveActionsThatHaveBeenCompleted) { + saveActions.remove(saveActionThatHasBeenCompleted); + } + + if (saveActions.size() == 0) { + exitPhoebusWithoutSavingUnsavedChanges_button.fire(); + } + }); + + // Initialize state of buttons: + enableAndDisableButtons.run(); + + prompt.setHeaderText(Messages.UnsavedChanges_theFollowingApplicationInstancesHaveUnsavedChanges + " " + question); + prompt.setTitle(Messages.UnsavedChanges); + + int prefWidth = 750; + int prefHeight = 400; + prompt.getDialogPane().setPrefSize(prefWidth, prefHeight); + prompt.getDialogPane().setMinSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); + prompt.setResizable(false); + + DialogHelper.positionDialog(prompt, stageToPositionTheConfirmationDialogOver.getScene().getRoot(), -prefWidth/2, -prefHeight/2); + + return prompt.showAndWait().orElse(ButtonType.CANCEL) == exitPhoebusWithoutSavingUnsavedChanges ? true : false; + }); + + if (windowNrToApplicationNameToDockItemsWithInput.isEmpty() || windowNrToApplicationNameToDockItemsWithInput.values().stream().allMatch(sortedMap -> sortedMap.values().stream().allMatch(Collection::isEmpty))) { + // No unsaved changes. + return true; + } + else { + Platform.runLater(displayConfirmationWindow); + boolean shouldClose = (boolean) displayConfirmationWindow.get(); + return shouldClose; + } + } + + private void exitPhoebus() { + Platform.runLater(() -> + { + for (Stage stage : DockStage.getDockStages()) { + DockStage.closeItems(stage); + } + stop(); + }); + }; + /** * Start all applications * @@ -1328,16 +1683,21 @@ public static void closeAllTabs(){ final List stages = DockStage.getDockStages(); JobManager.schedule("Close All Tabs", monitor -> { - for (Stage stage : stages){ - if (!DockStage.prepareToCloseItems(stage)){ - return; + boolean shouldCloseTabs = PhoebusApplication.confirmationDialogWhenUnsavedChangesExist(stages, + Messages.UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeClosingAllTabs, + Messages.UnsavedChanges_close, + PhoebusApplication.INSTANCE.main_stage, + monitor); + + if (shouldCloseTabs) { + for (Stage stage : stages){ + if (!DockStage.prepareToCloseItems(stage)){ + return; + } } - } - Platform.runLater(() -> - { - stages.forEach(stage -> DockStage.closeItems(stage)); - }); + Platform.runLater(() -> stages.forEach(stage -> DockStage.closeItems(stage))); + } }); } } diff --git a/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java b/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java index 0945cc79d2..eed796221a 100644 --- a/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java +++ b/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java @@ -24,11 +24,13 @@ import java.util.logging.Level; import java.util.stream.Collectors; +import javafx.event.Event; import org.phoebus.framework.jobs.JobManager; import org.phoebus.framework.spi.AppDescriptor; import org.phoebus.framework.spi.AppInstance; import org.phoebus.security.authorization.AuthorizationService; import org.phoebus.ui.application.Messages; +import org.phoebus.ui.application.PhoebusApplication; import org.phoebus.ui.application.SaveLayoutHelper; import org.phoebus.ui.dialog.DialogHelper; import org.phoebus.ui.javafx.ImageCache; @@ -175,16 +177,36 @@ public DockItem(final String label) createContextMenu(); + setOnCloseRequest(event -> handleCloseRequest(event)); setOnClosed(event -> handleClosed()); - setOnCloseRequest(event -> { - // Select the previously selected tab: - var dockPane = getDockPane(); - dockPane.tabsInOrderOfFocus.remove(this); - - if (dockPane.tabsInOrderOfFocus.size() > 0) { - var tabToSelect = dockPane.tabsInOrderOfFocus.getFirst(); - var selectionModel = dockPane.getSelectionModel(); - selectionModel.select(tabToSelect); + } + + private void handleCloseRequest(Event event) { + // For now, prevent closing + event.consume(); + + // Invoke all the ok-to-close checks in background threads + // since those that save files might take time. + JobManager.schedule("Close " + getLabel(), monitor -> + { + boolean shouldClose = this instanceof DockItemWithInput ? ((DockItemWithInput) this).okToClose().get() : true; + + if (shouldClose) { + var dockPane = getDockPane(); + Platform.runLater(() -> { + // Select the previously selected tab: + dockPane.tabsInOrderOfFocus.remove(this); + + if (dockPane.tabsInOrderOfFocus.size() > 0) { + var tabToSelect = dockPane.tabsInOrderOfFocus.getFirst(); + var selectionModel = dockPane.getSelectionModel(); + selectionModel.select(tabToSelect); + } + }); + + // Close the tab: + prepareToClose(); + Platform.runLater(() -> close()); } }); } @@ -230,14 +252,16 @@ else if (stagesContainingActiveDockPane.size() == 0) { }); final MenuItem close = new MenuItem(Messages.DockClose, new ImageView(DockPane.close_icon)); - close.setOnAction(event -> close(List.of(this))); + ArrayList arrayList = new ArrayList(); + arrayList.add(this); + close.setOnAction(event -> close(arrayList)); final MenuItem close_other = new MenuItem(Messages.DockCloseOthers, new ImageView(close_many_icon)); close_other.setOnAction(event -> { // Close all other tabs in non-fixed panes of this window final Stage stage = (Stage) getDockPane().getScene().getWindow(); - final List tabs = new ArrayList<>(); + final ArrayList tabs = new ArrayList<>(); for (DockPane pane : getDockPanes(stage)) if (! pane.isFixed()) for (Tab tab : new ArrayList<>(pane.getTabs())) @@ -251,7 +275,7 @@ else if (stagesContainingActiveDockPane.size() == 0) { { // Close all tabs in non-fixed panes of this window final Stage stage = (Stage) getDockPane().getScene().getWindow(); - final List tabs = new ArrayList<>(); + final ArrayList tabs = new ArrayList<>(); for (DockPane pane : getDockPanes(stage)) if (! pane.isFixed()) for (Tab tab : new ArrayList<>(pane.getTabs())) @@ -296,19 +320,28 @@ else if (stagesContainingActiveDockPane.size() == 0) { } /** @param tabs Tabs to prepare and then close */ - private static void close(final List tabs) + private void close(final ArrayList tabs) { JobManager.schedule("Close", monitor -> { - for (DockItem tab : tabs) - if (! tab.prepareToClose()) - return; - - Platform.runLater(() -> - { + Window window = getDockPane().getScene().getWindow(); + boolean shouldCloseTabs = PhoebusApplication.confirmationDialogWhenUnsavedChangesExist(tabs, + Messages.UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeClosingTheTabs, + Messages.UnsavedChanges_close, + window instanceof Stage ? (Stage) window : null, + monitor); + + if (shouldCloseTabs) { for (DockItem tab : tabs) - tab.close(); - }); + if (! tab.prepareToClose()) + return; + + Platform.runLater(() -> + { + for (DockItem tab : tabs) + tab.close(); + }); + } }); } @@ -604,25 +637,6 @@ public void select() */ public void addCloseCheck(final Supplier> ok_to_close) { - var alreadyExistingEventHandler = getOnCloseRequest(); - - setOnCloseRequest(event -> { - // For now, prevent closing - event.consume(); - - // Invoke all the ok-to-close checks in background threads - // since those that save files might take time. - JobManager.schedule("Close " + getLabel(), monitor -> - { - if (prepareToClose()) { - if (alreadyExistingEventHandler != null) { - alreadyExistingEventHandler.handle(event); - } - Platform.runLater(() -> close()); - } - }); - }); - close_check.add(ok_to_close); } @@ -706,9 +720,15 @@ protected void handleClosed() setContent(null); // Remove "application" entry which otherwise holds on to application data model getProperties().remove(KEY_APPLICATION); + + // Ensure that the tab is removed from dockPane.tabsInOrderOfFocus + // to avoid memory leaks. (The tab may have been closed without + // calling the OnCloseRequest event-handler.) var dockPane = getDockPane(); if (dockPane != null) { - dockPane.tabsInOrderOfFocus.remove(this); + Platform.runLater(() -> { + dockPane.tabsInOrderOfFocus.remove(this); + }); } } diff --git a/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java b/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java index 2b8c7d05a5..d4f029fc07 100644 --- a/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java +++ b/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java @@ -103,8 +103,6 @@ public DockItemWithInput(final AppInstance application, final Node content, fina this.file_extensions = file_extensions; this.save_handler = save_handler; setInput(input); - - addCloseCheck(this::okToClose); } // Override to include 'dirty' tab @@ -209,13 +207,13 @@ public boolean isSaveAsSupported() /** Called when user tries to close the tab * @return Should the tab close? Otherwise it stays open. */ - private Future okToClose() + public Future okToClose() { if (! isDirty()) return CompletableFuture.completedFuture(true); final FutureTask promptToSave = new FutureTask(() -> { - final String text = MessageFormat.format(Messages.DockAlertMsg, getLabel()); + final String text = MessageFormat.format(Messages.DockAlertMsg, getApplication().getAppDescriptor().getDisplayName(), getLabel()); final Alert prompt = new Alert(AlertType.NONE, text, ButtonType.NO, ButtonType.CANCEL, ButtonType.YES); @@ -245,7 +243,7 @@ private Future okToClose() final CompletableFuture done = new CompletableFuture<>(); JobManager.schedule(Messages.Save, monitor -> { - save(monitor); + save(monitor, getTabPane().getScene().getWindow()); // Indicate if we may close, or need to stay open because of error done.complete(!isDirty()); }); @@ -263,7 +261,7 @@ private Future okToClose() * @param monitor {@link JobMonitor} for reporting progress * @return true on success */ - public final boolean save(final JobMonitor monitor) + public final boolean save(final JobMonitor monitor, Window parentWindow) { // 'final' because any save customization should be possible // inside the save_handler @@ -274,7 +272,7 @@ public final boolean save(final JobMonitor monitor) // call save_as to prompt for file File file = ResourceParser.getFile(getInput()); if (file == null) - return save_as(monitor); + return save_as(monitor, parentWindow); if (file.exists() && !file.canWrite()) @@ -293,7 +291,7 @@ public final boolean save(final JobMonitor monitor) // If user doesn't want to overwrite, abort the save if (response.get() == ButtonType.OK) - return save_as(monitor); + return save_as(monitor, getTabPane().getScene().getWindow()); return false; } @@ -374,7 +372,7 @@ private static File setFileExtension(final File file, final List valid) * @param monitor {@link JobMonitor} for reporting progress * @return true on success */ - public final boolean save_as(final JobMonitor monitor) + public final boolean save_as(final JobMonitor monitor, Window parentWindow) { // 'final' because any save customization should be possible // inside the save_handler @@ -382,7 +380,7 @@ public final boolean save_as(final JobMonitor monitor) { // Prompt for file final File initial = ResourceParser.getFile(getInput()); - final File file = new SaveAsDialog().promptForFile(getTabPane().getScene().getWindow(), + final File file = new SaveAsDialog().promptForFile(parentWindow, Messages.SaveAs, initial, file_extensions); if (file == null) return false; @@ -402,7 +400,8 @@ public final boolean save_as(final JobMonitor monitor) file, valid.stream().collect(Collectors.joining(", ")), suggestion); - Platform.runLater(() -> + + Runnable confirmFileExtension = () -> { final Alert dialog = new Alert(AlertType.CONFIRMATION, prompt, ButtonType.YES, ButtonType.NO, ButtonType.CANCEL); dialog.setTitle(Messages.SaveAs); @@ -419,7 +418,15 @@ else if (response == ButtonType.NO) actual_file.complete(file); else actual_file.complete(null); - }); + }; + + if (Platform.isFxApplicationThread()) { + confirmFileExtension.run(); + } + else { + Platform.runLater(confirmFileExtension); + } + // In background thread, wait for the result if (actual_file.get() == null) return false; @@ -428,7 +435,7 @@ else if (response == ButtonType.NO) // Update input setInput(ResourceParser.getURI(actual_file.get())); // Save in that file - return save(monitor); + return save(monitor, getTabPane().getScene().getWindow()); } catch (Exception ex) { diff --git a/core/ui/src/main/java/org/phoebus/ui/docking/DockPane.java b/core/ui/src/main/java/org/phoebus/ui/docking/DockPane.java index 7191d08a1a..b6926e4d8b 100644 --- a/core/ui/src/main/java/org/phoebus/ui/docking/DockPane.java +++ b/core/ui/src/main/java/org/phoebus/ui/docking/DockPane.java @@ -22,8 +22,10 @@ import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; +import javafx.stage.Window; import org.phoebus.framework.jobs.JobManager; import org.phoebus.ui.application.Messages; +import org.phoebus.ui.application.PhoebusApplication; import org.phoebus.ui.dialog.DialogHelper; import org.phoebus.ui.javafx.ImageCache; import org.phoebus.ui.javafx.Styles; @@ -225,11 +227,13 @@ public static void alwaysShowTabs(final boolean do_show_single_tabs) setOnContextMenuRequested(this::showContextMenu); getSelectionModel().selectedItemProperty().addListener((observable, previous_item, new_item) -> { - // Keep track of the order of focus of tabs: - if (new_item != null) { - tabsInOrderOfFocus.remove(new_item); - tabsInOrderOfFocus.push((DockItem) new_item); - } + Platform.runLater(() -> { + // Keep track of the order of focus of tabs: + if (new_item != null) { + tabsInOrderOfFocus.remove(new_item); + tabsInOrderOfFocus.push((DockItem) new_item); + } + }); }); } @@ -345,11 +349,12 @@ private void handleGlobalKeys(final KeyEvent event) if (item instanceof DockItemWithInput) { final DockItemWithInput active_item_with_input = (DockItemWithInput) item; + if (event.isShiftDown()) { - JobManager.schedule(Messages.SaveAs, monitor -> active_item_with_input.save_as(monitor)); + JobManager.schedule(Messages.SaveAs, monitor -> active_item_with_input.save_as(monitor, active_item_with_input.getTabPane().getScene().getWindow())); } else if (active_item_with_input.isDirty()) { - JobManager.schedule(Messages.Save, monitor -> active_item_with_input.save(monitor)); + JobManager.schedule(Messages.Save, monitor -> active_item_with_input.save(monitor, active_item_with_input.getTabPane().getScene().getWindow())); } } event.consume(); @@ -360,8 +365,12 @@ else if (key == KeyCode.W) { JobManager.schedule("Close " + item.getLabel(), monitor -> { - if (item.prepareToClose()) + boolean shouldClose = item instanceof DockItemWithInput ? ((DockItemWithInput) item).okToClose().get() : true; + + if (shouldClose) { + item.prepareToClose(); Platform.runLater(item::close); + } }); } event.consume(); diff --git a/core/ui/src/main/java/org/phoebus/ui/docking/DockStage.java b/core/ui/src/main/java/org/phoebus/ui/docking/DockStage.java index b4a1efb195..12e3ca6769 100644 --- a/core/ui/src/main/java/org/phoebus/ui/docking/DockStage.java +++ b/core/ui/src/main/java/org/phoebus/ui/docking/DockStage.java @@ -16,8 +16,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; import java.util.UUID; import java.util.logging.Level; +import java.util.stream.Collectors; import org.phoebus.framework.jobs.JobManager; import org.phoebus.framework.workbench.Locations; @@ -191,8 +194,18 @@ else if(layout.getChildren().get(0) instanceof SplitPane){ // and on success close them JobManager.schedule("Close " + stage.getTitle(), monitor -> { - if (prepareToCloseItems(stage)) + boolean shouldCloseStage = PhoebusApplication.confirmationDialogWhenUnsavedChangesExist(stage, + Messages.UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeClosingTheWindow, + Messages.UnsavedChanges_close, + monitor); + + if (shouldCloseStage) { + if (!DockStage.prepareToCloseItems(stage)) { + return; + } + Platform.runLater(() -> closeItems(stage)); + } }); }); diff --git a/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties b/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties index 61d4ef4bd5..db0e2e1867 100644 --- a/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties +++ b/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties @@ -9,7 +9,7 @@ CloseAllTabs=Close All Tabs DeleteLayouts=Delete Layouts... DeleteLayoutsConfirmFmt=Delete {0} selected layouts? DeleteLayoutsInfo=Select layouts to delete -DockAlertMsg=The {0} has been modified.\n\nSave before closing? +DockAlertMsg=The {0} instance \"{1}\" has been modified.\n\nSave before closing? DockAlertTitle=Save File DockAll=All DockAppName=Application Name: @@ -132,6 +132,26 @@ TimeTime=Time: TimeYear=Year: TopResources=Top Resources UnLockPane=Un-lock Pane +UnsavedChanges=Unsaved changes +UnsavedChanges_clearButtonText=Clear +UnsavedChanges_close=close +UnsavedChanges_discardButtonText_discardAnd=Discard & +UnsavedChanges_exit=exit +UnsavedChanges_mainWindow=Main window +UnsavedChanges_replace=replace +UnsavedChanges_saveButtonText=Save +UnsavedChanges_saveButtonText_saveAnd=Save & +UnsavedChanges_saved=Saved +UnsavedChanges_saving=Saving... +UnsavedChanges_savingFailed=Saving failed +UnsavedChanges_secondaryWindow=Secondary window +UnsavedChanges_selectAllButtonText=Select all +UnsavedChanges_theFollowingApplicationInstancesHaveUnsavedChanges=The following application instances have unsaved changes. +UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeClosingAllTabs=Would you like to save any changes before closing all tabs? +UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeClosingTheTabs=Would you like to save any changes before closing the tabs? +UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeClosingTheWindow=Would you like to save any changes before closing the window? +UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeExiting=Would you like to save any changes before exiting? +UnsavedChanges_wouldYouLikeToSaveAnyChangesBeforeReplacingTheLayout=Would you like to save any changes before replacing the layout? WebBrowser=Web Browser Welcome=Welcome Window=Window