diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d376eedb1..8dc634ff1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Note that this project **does not** adhere to [Semantic Versioning](http://semve - We now show the number of items found and selected to import in the online search dialog. [#6248](https://github.com/JabRef/jabref/pull/6248) - We created a new install screen for macOS. [#5759](https://github.com/JabRef/jabref/issues/5759) - We implemented an option to download fulltext files while importing. [#6381](https://github.com/JabRef/jabref/pull/6381) +- We added a progress-indicator showing the average progress of background tasks to the toolbar. Clicking it reveals a pop-over with a list of running background tasks. [6443](https://github.com/JabRef/jabref/pull/6443) - We fixed the bug when strike the delete key in the text field. [#6421](https://github.com/JabRef/jabref/issues/6421) ### Changed diff --git a/src/main/java/org/jabref/Globals.java b/src/main/java/org/jabref/Globals.java index f89bc583772..4ee99f58804 100644 --- a/src/main/java/org/jabref/Globals.java +++ b/src/main/java/org/jabref/Globals.java @@ -44,8 +44,13 @@ public class Globals { // Remote listener public static final RemoteListenerServerLifecycle REMOTE_LISTENER = new RemoteListenerServerLifecycle(); + /** + * Manager for the state of the GUI. + */ + public static StateManager stateManager = new StateManager(); + public static final ImportFormatReader IMPORT_FORMAT_READER = new ImportFormatReader(); - public static final TaskExecutor TASK_EXECUTOR = new DefaultTaskExecutor(); + public static final TaskExecutor TASK_EXECUTOR = new DefaultTaskExecutor(stateManager); /** * Each test case initializes this field if required @@ -64,11 +69,6 @@ public class Globals { */ public static ProtectedTermsLoader protectedTermsLoader; - /** - * Manager for the state of the GUI. - */ - public static StateManager stateManager = new StateManager(); - public static ExporterFactory exportFactory; public static CountingUndoManager undoManager = new CountingUndoManager(); public static BibEntryTypesManager entryTypesManager = new BibEntryTypesManager(); diff --git a/src/main/java/org/jabref/gui/Base.css b/src/main/java/org/jabref/gui/Base.css index 77e618a818a..9d8f0a00ee8 100644 --- a/src/main/java/org/jabref/gui/Base.css +++ b/src/main/java/org/jabref/gui/Base.css @@ -387,6 +387,24 @@ -fx-padding: -0.1em 0.5em 0.5em 0.5em; } +.progress-indicator { + -fx-progress-color: -jr-theme; + -fx-border-width: 0px; + -fx-background-color: -jr-icon-background; +} + +.progress-indicator:hover { + -fx-background-color: -jr-icon-background-active; +} + +.progress-indicatorToolbar { + -fx-padding: 0.5em; +} + +.progress-indicatorToolbar .percentage { + -fx-fill:null; +} + .check-box { -fx-label-padding: 0.0em 0.0em 0.0em 0.75em; -fx-text-fill: -fx-text-background-color; diff --git a/src/main/java/org/jabref/gui/DialogService.java b/src/main/java/org/jabref/gui/DialogService.java index 2ca281876b0..87da6a3b517 100644 --- a/src/main/java/org/jabref/gui/DialogService.java +++ b/src/main/java/org/jabref/gui/DialogService.java @@ -192,8 +192,21 @@ Optional showCustomButtonDialogAndWait(Alert.AlertType type, String * @param title title of the dialog * @param content message to show above the progress bar * @param task The {@link Task} which executes the work and for which to show the dialog + * @return */ - void showProgressDialogAndWait(String title, String content, Task task); + Optional showProgressDialogAndWait(String title, String content, Task task); + + /** + * Constructs and shows a dialog showing the progress of running background tasks. + * Clicking cancel will cancel the underlying service and close the dialog. + * The dialog will exit as soon as none of the background tasks are running + * + * @param title title of the dialog + * @param content message to show below the list of background tasks + * @param stateManager The {@link StateManager} which contains the background tasks + * @return + */ + Optional showBackgroundProgressDialogAndWait(String title, String content, StateManager stateManager); /** * Notify the user in an non-blocking way (i.e., in form of toast in a snackbar). diff --git a/src/main/java/org/jabref/gui/JabRefDialogService.java b/src/main/java/org/jabref/gui/JabRefDialogService.java index 6c0ccf37611..d1a190c4979 100644 --- a/src/main/java/org/jabref/gui/JabRefDialogService.java +++ b/src/main/java/org/jabref/gui/JabRefDialogService.java @@ -23,9 +23,11 @@ import javafx.scene.control.CheckBox; import javafx.scene.control.ChoiceDialog; import javafx.scene.control.DialogPane; +import javafx.scene.control.Label; import javafx.scene.control.TextInputDialog; import javafx.scene.layout.Pane; import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; import javafx.stage.DirectoryChooser; import javafx.stage.FileChooser; import javafx.stage.Stage; @@ -33,6 +35,7 @@ import javafx.util.Duration; import org.jabref.gui.icon.IconTheme; +import org.jabref.gui.util.BackgroundTask; import org.jabref.gui.util.DirectoryDialogConfiguration; import org.jabref.gui.util.FileDialogConfiguration; import org.jabref.gui.util.ThemeLoader; @@ -43,8 +46,10 @@ import com.jfoenix.controls.JFXSnackbar; import com.jfoenix.controls.JFXSnackbar.SnackbarEvent; import com.jfoenix.controls.JFXSnackbarLayout; +import org.controlsfx.control.TaskProgressView; import org.controlsfx.dialog.ExceptionDialog; import org.controlsfx.dialog.ProgressDialog; +import org.fxmisc.easybind.EasyBind; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -267,7 +272,7 @@ public Optional showCustomDialogAndWait(Dialog dialog) { } @Override - public void showProgressDialogAndWait(String title, String content, Task task) { + public Optional showProgressDialogAndWait(String title, String content, Task task) { ProgressDialog progressDialog = new ProgressDialog(task); progressDialog.setHeaderText(null); progressDialog.setTitle(title); @@ -283,7 +288,40 @@ public void showProgressDialogAndWait(String title, String content, Task progressDialog.close(); }); themeLoader.installCss(progressDialog.getDialogPane().getScene(), preferences); - progressDialog.show(); + return progressDialog.showAndWait(); + } + + @Override + public Optional showBackgroundProgressDialogAndWait(String title, String content, StateManager stateManager) { + TaskProgressView taskProgressView = new TaskProgressView(); + EasyBind.listBind(taskProgressView.getTasks(), stateManager.getBackgroundTasks()); + taskProgressView.setRetainTasks(false); + taskProgressView.setGraphicFactory(BackgroundTask::getIcon); + + Label message = new Label(content); + + VBox box = new VBox(taskProgressView, message); + + DialogPane contentPane = new DialogPane(); + contentPane.setContent(box); + + FXDialog alert = new FXDialog(AlertType.WARNING, title); + alert.setDialogPane(contentPane); + alert.getButtonTypes().setAll(ButtonType.YES, ButtonType.CANCEL); + alert.getDialogPane().setMinHeight(Region.USE_PREF_SIZE); + alert.setResizable(true); + themeLoader.installCss(alert.getDialogPane().getScene(), preferences); + + stateManager.getAnyTaskRunning().addListener((observable, oldValue, newValue) -> { + if (!newValue) { + alert.setResult(ButtonType.YES); + alert.close(); + } + }); + + Dialog dialog = () -> alert.showAndWait(); + + return showCustomDialogAndWait(dialog); } @Override diff --git a/src/main/java/org/jabref/gui/JabRefFrame.java b/src/main/java/org/jabref/gui/JabRefFrame.java index 3dcb7450849..67d97243523 100644 --- a/src/main/java/org/jabref/gui/JabRefFrame.java +++ b/src/main/java/org/jabref/gui/JabRefFrame.java @@ -15,6 +15,7 @@ import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.geometry.Orientation; +import javafx.scene.Group; import javafx.scene.Node; import javafx.scene.control.Alert; import javafx.scene.control.Button; @@ -23,6 +24,7 @@ import javafx.scene.control.Menu; import javafx.scene.control.MenuBar; import javafx.scene.control.MenuItem; +import javafx.scene.control.ProgressIndicator; import javafx.scene.control.Separator; import javafx.scene.control.SeparatorMenuItem; import javafx.scene.control.SplitPane; @@ -38,6 +40,7 @@ import javafx.scene.layout.Pane; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; +import javafx.scene.shape.Rectangle; import javafx.stage.Stage; import org.jabref.Globals; @@ -135,6 +138,8 @@ import org.jabref.preferences.LastFocusedTabPreferences; import com.google.common.eventbus.Subscribe; +import org.controlsfx.control.PopOver; +import org.controlsfx.control.TaskProgressView; import org.fxmisc.easybind.EasyBind; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -397,7 +402,23 @@ private void tearDownJabRef(List filenames) { * @return true if the user chose to quit; false otherwise */ public boolean quit() { - // First ask if the user really wants to close, if the library has not been saved since last save. + // First ask if the user really wants to close, if there are still background tasks running + /* + It is important to wait for unfinished background tasks before checking if a save-operation is needed, because + the background tasks may make changes themselves that need saving. + */ + if (stateManager.getAnyTaskRunning().getValue()) { + Optional shouldClose = dialogService.showBackgroundProgressDialogAndWait( + Localization.lang("Please wait..."), + Localization.lang("Waiting for background tasks to finish. Quit anyway?"), + stateManager + ); + if (!(shouldClose.isPresent() && shouldClose.get() == ButtonType.YES)) { + return false; + } + } + + // Then ask if the user really wants to close, if the library has not been saved since last save. List filenames = new ArrayList<>(); for (int i = 0; i < tabbedPane.getTabs().size(); i++) { BasePanel panel = getBasePanelAt(i); @@ -517,7 +538,9 @@ private Node createToolbar() { new Separator(Orientation.VERTICAL), factory.createIconButton(StandardActions.OPEN_GITHUB, new OpenBrowserAction("https://github.com/JabRef/jabref")), factory.createIconButton(StandardActions.OPEN_FACEBOOK, new OpenBrowserAction("https://www.facebook.com/JabRef/")), - factory.createIconButton(StandardActions.OPEN_TWITTER, new OpenBrowserAction("https://twitter.com/jabref_org")) + factory.createIconButton(StandardActions.OPEN_TWITTER, new OpenBrowserAction("https://twitter.com/jabref_org")), + new Separator(Orientation.VERTICAL), + createTaskIndicator() ); HBox.setHgrow(globalSearchBar, Priority.ALWAYS); @@ -921,6 +944,59 @@ private MenuBar createMenu() { return menu; } + private Group createTaskIndicator() { + ProgressIndicator indicator = new ProgressIndicator(); + indicator.getStyleClass().add("progress-indicatorToolbar"); + indicator.progressProperty().bind(stateManager.getTasksProgress()); + + Tooltip someTasksRunning = new Tooltip(Localization.lang("Background Tasks are running")); + Tooltip noTasksRunning = new Tooltip(Localization.lang("Background Tasks are done")); + indicator.setTooltip(noTasksRunning); + stateManager.getAnyTaskRunning().addListener(new ChangeListener() { + @Override + public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { + if (newValue.booleanValue()) { + indicator.setTooltip(someTasksRunning); + } else { + indicator.setTooltip(noTasksRunning); + } + } + }); + + /* + The label of the indicator cannot be removed with styling. Therefore, + hide it and clip it to a square of (width x width) each time width is updated. + */ + indicator.widthProperty().addListener((observable, oldValue, newValue) -> { + /* + The indeterminate spinner is wider than the determinate spinner. + We must make sure they are the same width for the clipping to result in a square of the same size always. + */ + if (!indicator.isIndeterminate()) { + indicator.setPrefWidth(newValue.doubleValue()); + } + if (newValue.doubleValue() > 0) { + Rectangle clip = new Rectangle(newValue.doubleValue(), newValue.doubleValue()); + indicator.setClip(clip); + } + }); + + indicator.setOnMouseClicked(event -> { + TaskProgressView taskProgressView = new TaskProgressView(); + EasyBind.listBind(taskProgressView.getTasks(), stateManager.getBackgroundTasks()); + taskProgressView.setRetainTasks(true); + taskProgressView.setGraphicFactory(BackgroundTask::getIcon); + + PopOver progressViewPopOver = new PopOver(taskProgressView); + progressViewPopOver.setTitle(Localization.lang("Background Tasks")); + progressViewPopOver.setArrowLocation(PopOver.ArrowLocation.RIGHT_TOP); + + progressViewPopOver.show(indicator); + }); + + return new Group(indicator); + } + public void addParserResult(ParserResult parserResult, boolean focusPanel) { if (parserResult.toOpenTab()) { // Add the entries to the open tab. diff --git a/src/main/java/org/jabref/gui/StateManager.java b/src/main/java/org/jabref/gui/StateManager.java index 05a9364f65d..235a49ec4de 100644 --- a/src/main/java/org/jabref/gui/StateManager.java +++ b/src/main/java/org/jabref/gui/StateManager.java @@ -5,7 +5,10 @@ import java.util.Optional; import java.util.stream.Collectors; +import javafx.beans.Observable; import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.binding.DoubleBinding; import javafx.beans.property.IntegerProperty; import javafx.beans.property.ReadOnlyListProperty; import javafx.beans.property.ReadOnlyListWrapper; @@ -13,6 +16,7 @@ import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.collections.ObservableMap; +import javafx.concurrent.Task; import javafx.scene.Node; import org.jabref.gui.util.CustomLocalDragboard; @@ -41,6 +45,17 @@ public class StateManager { private final OptionalObjectProperty activeSearchQuery = OptionalObjectProperty.empty(); private final ObservableMap searchResultMap = FXCollections.observableHashMap(); private final OptionalObjectProperty focusOwner = OptionalObjectProperty.empty(); + private final ObservableList> backgroundTasks = FXCollections.observableArrayList(taskProperty -> { + return new Observable[] {taskProperty.progressProperty(), taskProperty.runningProperty()}; + }); + + private BooleanBinding anyTaskRunning = Bindings.createBooleanBinding( + () -> backgroundTasks.stream().anyMatch(Task::isRunning), backgroundTasks + ); + + private DoubleBinding tasksProgress = Bindings.createDoubleBinding( + () -> backgroundTasks.stream().filter(Task::isRunning).mapToDouble(Task::getProgress).average().orElse(1), backgroundTasks + ); public StateManager() { activeGroups.bind(Bindings.valueAt(selectedGroups, activeDatabase.orElse(null))); @@ -112,4 +127,20 @@ public void setSearchQuery(SearchQuery searchQuery) { public OptionalObjectProperty focusOwnerProperty() { return focusOwner; } public Optional getFocusOwner() { return focusOwner.get(); } + + public ObservableList> getBackgroundTasks() { + return backgroundTasks; + } + + public void addBackgroundTask(Task backgroundTask) { + this.backgroundTasks.add(0, backgroundTask); + } + + public BooleanBinding getAnyTaskRunning() { + return anyTaskRunning; + } + + public DoubleBinding getTasksProgress() { + return tasksProgress; + } } diff --git a/src/main/java/org/jabref/gui/externalfiles/DownloadFullTextAction.java b/src/main/java/org/jabref/gui/externalfiles/DownloadFullTextAction.java index 52002cba4dd..ba6dbaa4b2e 100644 --- a/src/main/java/org/jabref/gui/externalfiles/DownloadFullTextAction.java +++ b/src/main/java/org/jabref/gui/externalfiles/DownloadFullTextAction.java @@ -166,6 +166,10 @@ private void addLinkedFileFromURL(BibDatabaseContext databaseContext, URL url, B dialogService.notify(Localization.lang("Finished downloading full text document for entry %0.", entry.getCiteKeyOptional().orElse(Localization.lang("undefined")))); }); + downloadTask.titleProperty().set(Localization.lang("Downloading")); + downloadTask.messageProperty().set( + Localization.lang("Fulltext for") + ": " + entry.getCiteKeyOptional().orElse(Localization.lang("New entry"))); + downloadTask.showToUser(true); Globals.TASK_EXECUTOR.execute(downloadTask); } catch (MalformedURLException exception) { dialogService.showErrorDialogAndWait(Localization.lang("Invalid URL"), exception); diff --git a/src/main/java/org/jabref/gui/fieldeditors/LinkedFileViewModel.java b/src/main/java/org/jabref/gui/fieldeditors/LinkedFileViewModel.java index 352cff0eaa3..3ea362ebb1c 100644 --- a/src/main/java/org/jabref/gui/fieldeditors/LinkedFileViewModel.java +++ b/src/main/java/org/jabref/gui/fieldeditors/LinkedFileViewModel.java @@ -417,6 +417,10 @@ public void download() { entry.addFile(0, newLinkedFile); }); downloadProgress.bind(downloadTask.workDonePercentageProperty()); + downloadTask.titleProperty().set(Localization.lang("Downloading")); + downloadTask.messageProperty().set( + Localization.lang("Fulltext for") + ": " + entry.getCiteKeyOptional().orElse(Localization.lang("New entry"))); + downloadTask.showToUser(true); taskExecutor.execute(downloadTask); } catch (MalformedURLException exception) { dialogService.showErrorDialogAndWait(Localization.lang("Invalid URL"), exception); diff --git a/src/main/java/org/jabref/gui/util/BackgroundTask.java b/src/main/java/org/jabref/gui/util/BackgroundTask.java index f3f2cd24d09..2b9e7b65d2a 100644 --- a/src/main/java/org/jabref/gui/util/BackgroundTask.java +++ b/src/main/java/org/jabref/gui/util/BackgroundTask.java @@ -15,7 +15,12 @@ import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.concurrent.Task; +import javafx.scene.Node; +import org.jabref.gui.icon.IconTheme; +import org.jabref.logic.l10n.Localization; + +import com.google.common.collect.ImmutableMap; import org.fxmisc.easybind.EasyBind; /** @@ -27,6 +32,11 @@ * @param type of the return value of the task */ public abstract class BackgroundTask { + + public static ImmutableMap iconMap = ImmutableMap.of( + Localization.lang("Downloading"), IconTheme.JabRefIcons.DOWNLOAD.getGraphicNode() + ); + private Runnable onRunning; private Consumer onSuccess; private Consumer onException; @@ -34,7 +44,9 @@ public abstract class BackgroundTask { private BooleanProperty isCanceled = new SimpleBooleanProperty(false); private ObjectProperty progress = new SimpleObjectProperty<>(new BackgroundProgress(0, 0)); private StringProperty message = new SimpleStringProperty(""); + private StringProperty title = new SimpleStringProperty(this.getClass().getSimpleName()); private DoubleProperty workDonePercentage = new SimpleDoubleProperty(0); + private BooleanProperty showToUser = new SimpleBooleanProperty(false); public BackgroundTask() { workDonePercentage.bind(EasyBind.map(progress, BackgroundTask.BackgroundProgress::getWorkDonePercentage)); @@ -90,6 +102,10 @@ public StringProperty messageProperty() { return message; } + public StringProperty titleProperty() { + return title; + } + public double getWorkDonePercentage() { return workDonePercentage.get(); } @@ -106,6 +122,14 @@ public ObjectProperty progressProperty() { return progress; } + public boolean showToUser() { + return showToUser.get(); + } + + public void showToUser(boolean show) { + showToUser.set(show); + } + /** * Sets the {@link Runnable} that is invoked after the task is started. */ @@ -233,6 +257,13 @@ public BackgroundTask withInitialMessage(String message) { return this; } + public static Node getIcon(Object task) { + if (task instanceof Task) { + return BackgroundTask.iconMap.getOrDefault(((Task) task).getTitle(), null); + } + return null; + } + static class BackgroundProgress { private final double workDone; diff --git a/src/main/java/org/jabref/gui/util/DefaultTaskExecutor.java b/src/main/java/org/jabref/gui/util/DefaultTaskExecutor.java index 91bf5da2180..6cbb6b27515 100644 --- a/src/main/java/org/jabref/gui/util/DefaultTaskExecutor.java +++ b/src/main/java/org/jabref/gui/util/DefaultTaskExecutor.java @@ -16,6 +16,7 @@ import javafx.application.Platform; import javafx.concurrent.Task; +import org.jabref.gui.StateManager; import org.jabref.logic.util.DelayTaskThrottler; import org.slf4j.Logger; @@ -33,6 +34,13 @@ public class DefaultTaskExecutor implements TaskExecutor { private final ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(2); private final WeakHashMap throttlers = new WeakHashMap<>(); + private final StateManager stateManager; + + public DefaultTaskExecutor(StateManager stateManager) { + super(); + this.stateManager = stateManager; + } + /** * */ @@ -96,7 +104,11 @@ public static void runInJavaFXThread(Runnable runnable) { @Override public Future execute(BackgroundTask task) { - return execute(getJavaFXTask(task)); + Task javafxTask = getJavaFXTask(task); + if (task.showToUser()) { + stateManager.addBackgroundTask(javafxTask); + } + return execute(javafxTask); } @Override @@ -128,8 +140,11 @@ private Task getJavaFXTask(BackgroundTask task) { Task javaTask = new Task() { { + this.updateMessage(task.messageProperty().get()); + this.updateTitle(task.titleProperty().get()); BindingsHelper.subscribeFuture(task.progressProperty(), progress -> updateProgress(progress.getWorkDone(), progress.getMax())); BindingsHelper.subscribeFuture(task.messageProperty(), this::updateMessage); + BindingsHelper.subscribeFuture(task.titleProperty(), this::updateTitle); BindingsHelper.subscribeFuture(task.isCanceledProperty(), cancelled -> { if (cancelled) { cancel(); diff --git a/src/main/resources/l10n/JabRef_en.properties b/src/main/resources/l10n/JabRef_en.properties index eebbfc6bc94..22c3c891ddf 100644 --- a/src/main/resources/l10n/JabRef_en.properties +++ b/src/main/resources/l10n/JabRef_en.properties @@ -92,6 +92,12 @@ Available\ import\ formats=Available import formats %0\ source=%0 source +Background\ Tasks=Background Tasks + +Background\ Tasks\ are\ running=Background Tasks are running + +Background\ Tasks\ are\ done=Background Tasks are done + Browse=Browse by=by @@ -209,6 +215,8 @@ Default\ encoding=Default encoding Default\ grouping\ field=Default grouping field +Downloading=Downloading + Execute\ default\ action\ in\ dialog=Execute default action in dialog Delete=Delete @@ -368,6 +376,8 @@ Formatter\ name=Formatter name found\ in\ AUX\ file=found in AUX file +Fulltext\ for=Fulltext for + Further\ information\ about\ Mr.\ DLib\ for\ JabRef\ users.=Further information about Mr. DLib for JabRef users. General=General @@ -999,6 +1009,7 @@ search\ expression=search expression Optional\ fields\ 2=Optional fields 2 Waiting\ for\ save\ operation\ to\ finish=Waiting for save operation to finish +Waiting\ for\ background\ tasks\ to\ finish.\ Quit\ anyway?=Waiting for background tasks to finish. Quit anyway? Find\ and\ remove\ duplicate\ BibTeX\ keys=Find and remove duplicate BibTeX keys Expected\ syntax\ for\ --fetch\='\:'=Expected syntax for --fetch=':'