diff --git a/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java b/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java index b1af3c9aa24..761fd2b0473 100644 --- a/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java +++ b/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java @@ -92,6 +92,7 @@ public static void setup(List uiCommands, @Override public void start(Stage stage) { this.mainStage = stage; + Injector.setModelOrService(Stage.class, mainStage); FallbackExceptionHandler.installExceptionHandler((exception, thread) -> UiTaskExecutor.runInJavaFXThread(() -> { DialogService dialogService = Injector.instantiateModelOrService(DialogService.class); diff --git a/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java b/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java index 446511c7857..f21144c1d0a 100644 --- a/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java +++ b/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java @@ -185,6 +185,8 @@ public enum StandardActions implements Action { OPEN_DEV_VERSION_LINK(Localization.lang("Development version"), Localization.lang("Opens a link where the current development version can be downloaded")), OPEN_CHANGELOG(Localization.lang("View change log"), Localization.lang("See what has been changed in the JabRef versions")), OPEN_GITHUB("GitHub", Localization.lang("Opens JabRef's GitHub page"), IconTheme.JabRefIcons.GITHUB), + WALKTHROUGH_MENU(Localization.lang("Walkthroughs"), IconTheme.JabRefIcons.BOOK), + MAIN_FILE_DIRECTORY_WALKTHROUGH(Localization.lang("Configure main file directory"), IconTheme.JabRefIcons.LATEX_FILE_DIRECTORY), DONATE(Localization.lang("Donate to JabRef"), Localization.lang("Donate to JabRef"), IconTheme.JabRefIcons.DONATE), OPEN_FORUM(Localization.lang("Community forum"), Localization.lang("Community forum"), IconTheme.JabRefIcons.FORUM), ERROR_CONSOLE(Localization.lang("View event log"), Localization.lang("Display all error messages")), diff --git a/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrame.java b/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrame.java index 4280e9b5ff4..3fa13641fb7 100644 --- a/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrame.java +++ b/jabgui/src/main/java/org/jabref/gui/frame/JabRefFrame.java @@ -630,6 +630,7 @@ public void openLastEditedDatabases() { getOpenDatabaseAction().openFiles(lastFiles); } + @Deprecated public Stage getMainStage() { return mainStage; } diff --git a/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java b/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java index 38ff808982c..78654f6939d 100644 --- a/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java +++ b/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java @@ -7,6 +7,7 @@ import javafx.scene.control.MenuBar; import javafx.scene.control.MenuItem; import javafx.scene.control.SeparatorMenuItem; +import javafx.stage.Stage; import org.jabref.gui.ClipBoardManager; import org.jabref.gui.DialogService; @@ -77,6 +78,7 @@ import org.jabref.gui.undo.UndoAction; import org.jabref.gui.util.URLs; import org.jabref.gui.util.UiTaskExecutor; +import org.jabref.gui.walkthrough.WalkthroughAction; import org.jabref.logic.ai.AiService; import org.jabref.logic.citationstyle.CitationStyleOutputFormat; import org.jabref.logic.help.HelpFile; @@ -90,6 +92,8 @@ import org.jabref.model.entry.field.SpecialField; import org.jabref.model.util.FileUpdateMonitor; +import com.airhacks.afterburner.injection.Injector; + public class MainMenu extends MenuBar { private final JabRefFrame frame; private final FileHistoryMenu fileHistoryMenu; @@ -235,7 +239,9 @@ private void createMenu() { edit.addEventHandler(ActionEvent.ACTION, event -> { // Work around for mac only issue, where cmd+v on a dialogue triggers the paste action of menu item, resulting in addition of the pasted content in the MainTable. // If the mainscreen is not focused, the actions captured by menu are consumed. - if (OS.OS_X && !frame.getMainStage().focusedProperty().get()) { + boolean isStageUnfocused = !Injector.instantiateModelOrService(Stage.class).focusedProperty().get(); + + if (OS.OS_X && isStageUnfocused) { event.consume(); } }); @@ -369,6 +375,12 @@ private void createMenu() { new SeparatorMenuItem(), + factory.createSubMenu(StandardActions.WALKTHROUGH_MENU, + factory.createMenuItem(StandardActions.MAIN_FILE_DIRECTORY_WALKTHROUGH, new WalkthroughAction("mainFileDirectory")) + ), + + new SeparatorMenuItem(), + factory.createMenuItem(StandardActions.ERROR_CONSOLE, new ErrorConsoleAction()), new SeparatorMenuItem(), diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/PreferencesDialogView.java b/jabgui/src/main/java/org/jabref/gui/preferences/PreferencesDialogView.java index dfebe248e69..617954e645e 100644 --- a/jabgui/src/main/java/org/jabref/gui/preferences/PreferencesDialogView.java +++ b/jabgui/src/main/java/org/jabref/gui/preferences/PreferencesDialogView.java @@ -31,6 +31,7 @@ */ public class PreferencesDialogView extends BaseDialog { + public static final String DIALOG_TITLE = Localization.lang("JabRef preferences"); @FXML private CustomTextField searchBox; @FXML private ListView preferenceTabList; @FXML private ScrollPane preferencesContainer; @@ -45,7 +46,7 @@ public class PreferencesDialogView extends BaseDialog preferencesTabToSelectClass; public PreferencesDialogView(Class preferencesTabToSelectClass) { - this.setTitle(Localization.lang("JabRef preferences")); + this.setTitle(DIALOG_TITLE); this.preferencesTabToSelectClass = preferencesTabToSelectClass; ViewLoader.view(this) diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/SingleWindowWalkthroughOverlay.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/SingleWindowWalkthroughOverlay.java new file mode 100644 index 00000000000..62ecfcf2888 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/SingleWindowWalkthroughOverlay.java @@ -0,0 +1,252 @@ +package org.jabref.gui.walkthrough; + +import java.util.Optional; + +import javafx.beans.value.ChangeListener; +import javafx.geometry.Bounds; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.RowConstraints; +import javafx.scene.layout.StackPane; +import javafx.scene.shape.Rectangle; +import javafx.stage.Window; + +import org.jabref.gui.walkthrough.declarative.step.PanelPosition; +import org.jabref.gui.walkthrough.declarative.step.PanelStep; +import org.jabref.gui.walkthrough.declarative.step.TooltipPosition; +import org.jabref.gui.walkthrough.declarative.step.TooltipStep; +import org.jabref.gui.walkthrough.declarative.step.WalkthroughStep; + +import org.controlsfx.control.PopOver; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages the overlay for displaying walkthrough steps in a single window. + */ +public class SingleWindowWalkthroughOverlay { + private static final Logger LOGGER = LoggerFactory.getLogger(SingleWindowWalkthroughOverlay.class); + + private final Window window; + private final GridPane overlayPane; + private final Pane originalRoot; + private final StackPane stackPane; + private final WalkthroughRenderer renderer; + private final WalkthroughUpdater updater = new WalkthroughUpdater(); + + public SingleWindowWalkthroughOverlay(Window window) { + this.window = window; + this.renderer = new WalkthroughRenderer(); + + overlayPane = new GridPane(); + overlayPane.getStyleClass().add("walkthrough-overlay"); + overlayPane.setPickOnBounds(false); + overlayPane.setMaxWidth(Double.MAX_VALUE); + overlayPane.setMaxHeight(Double.MAX_VALUE); + + Scene scene = window.getScene(); + // This basically never happens, so only a development time check is needed + assert scene != null; + + originalRoot = (Pane) scene.getRoot(); + stackPane = new StackPane(); + + stackPane.getChildren().add(originalRoot); + stackPane.getChildren().add(overlayPane); + + scene.setRoot(stackPane); + } + + /// Display a walkthrough step without a target node. + public void displayStep(WalkthroughStep step, Runnable beforeNavigate, Walkthrough walkthrough) { + displayStep(step, null, beforeNavigate, walkthrough); + } + + /// Displays a walkthrough step, with or without a target node. + public void displayStep(WalkthroughStep step, + @Nullable Node targetNode, + Runnable beforeNavigate, + Walkthrough walkthrough) { + hide(); + + switch (step) { + case TooltipStep tooltipStep -> { + Node content = renderer.render(tooltipStep, walkthrough, beforeNavigate); + displayTooltipStep(content, targetNode, tooltipStep); + hideOverlayPane(); + } + case PanelStep panelStep -> { + Node content = renderer.render(panelStep, walkthrough, beforeNavigate); + displayPanelStep(content, panelStep); + setupClipping(content); + overlayPane.toFront(); + } + } + + if (targetNode == null) { + return; + } + + step.navigationPredicate().ifPresent(predicate -> updater + .addCleanupTask(predicate.attachListeners(targetNode, beforeNavigate, walkthrough::nextStep))); + } + + /** + * Hide the overlay and clean up any resources. + */ + public void hide() { + overlayPane.getChildren().clear(); + overlayPane.setClip(null); + overlayPane.setVisible(true); + updater.cleanup(); + } + + /** + * Detaches the overlay and restores the original scene root. + */ + public void detach() { + hide(); + + Scene scene = window.getScene(); + if (scene != null && originalRoot != null) { + stackPane.getChildren().remove(originalRoot); + scene.setRoot(originalRoot); + LOGGER.debug("Restored original scene root: {}", originalRoot.getClass().getName()); + } + } + + private void displayTooltipStep(Node content, @Nullable Node targetNode, TooltipStep step) { + PopOver popover = new PopOver(); + popover.getScene().getStylesheets().setAll(window.getScene().getStylesheets()); // FIXME: walkaround to prevent popover from not properly inheriting styles + popover.setContentNode(content); + popover.setDetachable(false); + popover.setCloseButtonEnabled(false); + popover.setHeaderAlwaysVisible(false); + mapToArrowLocation(step.position()).ifPresent(popover::setArrowLocation); + popover.setAutoHide(false); + popover.setAutoFix(true); + + if (targetNode == null) { + popover.show(window); + return; + } + + popover.show(targetNode); + updater.addCleanupTask(popover::hide); + Runnable showPopover = () -> { + if (WalkthroughUpdater.cannotPositionNode(targetNode)) { + return; + } + popover.show(targetNode); + }; + updater.setupScrollContainerListeners(targetNode, showPopover); + } + + private void displayPanelStep(Node content, PanelStep step) { + overlayPane.getChildren().clear(); + overlayPane.getRowConstraints().clear(); + overlayPane.getColumnConstraints().clear(); + + configurePanelLayout(step.position()); + + overlayPane.getChildren().add(content); + GridPane.setHgrow(content, Priority.NEVER); + GridPane.setVgrow(content, Priority.NEVER); + + switch (step.position()) { + case LEFT -> { + overlayPane.setAlignment(Pos.CENTER_LEFT); + GridPane.setVgrow(content, Priority.ALWAYS); + GridPane.setFillHeight(content, true); + } + case RIGHT -> { + overlayPane.setAlignment(Pos.CENTER_RIGHT); + GridPane.setVgrow(content, Priority.ALWAYS); + GridPane.setFillHeight(content, true); + } + case TOP -> { + overlayPane.setAlignment(Pos.TOP_CENTER); + GridPane.setHgrow(content, Priority.ALWAYS); + GridPane.setFillWidth(content, true); + } + case BOTTOM -> { + overlayPane.setAlignment(Pos.BOTTOM_CENTER); + GridPane.setHgrow(content, Priority.ALWAYS); + GridPane.setFillWidth(content, true); + } + default -> { + LOGGER.warn("Unsupported position for panel step: {}", step.position()); + overlayPane.setAlignment(Pos.CENTER); + } + } + } + + private void configurePanelLayout(PanelPosition position) { + overlayPane.getRowConstraints().add(switch (position) { + case LEFT, + RIGHT -> { + RowConstraints rowConstraints = new RowConstraints(); + rowConstraints.setVgrow(Priority.ALWAYS); + yield rowConstraints; + } + case TOP, + BOTTOM -> { + RowConstraints rowConstraints = new RowConstraints(); + rowConstraints.setVgrow(Priority.NEVER); + yield rowConstraints; + } + }); + overlayPane.getColumnConstraints().add(switch (position) { + case LEFT, + RIGHT -> { + ColumnConstraints columnConstraints = new ColumnConstraints(); + columnConstraints.setHgrow(Priority.NEVER); + yield columnConstraints; + } + case TOP, + BOTTOM -> { + ColumnConstraints columnConstraints = new ColumnConstraints(); + columnConstraints.setHgrow(Priority.ALWAYS); + yield columnConstraints; + } + }); + } + + private Optional mapToArrowLocation(TooltipPosition position) { + return Optional.ofNullable(switch (position) { + case TOP -> + PopOver.ArrowLocation.BOTTOM_CENTER; + case BOTTOM -> + PopOver.ArrowLocation.TOP_CENTER; + case LEFT -> + PopOver.ArrowLocation.RIGHT_CENTER; + case RIGHT -> + PopOver.ArrowLocation.LEFT_CENTER; + case AUTO -> + null; + }); + } + + private void hideOverlayPane() { + overlayPane.setVisible(false); + updater.addCleanupTask(() -> overlayPane.setVisible(true)); + } + + private void setupClipping(Node node) { + ChangeListener listener = (_, _, bounds) -> { + if (bounds != null && bounds.getWidth() > 0 && bounds.getHeight() > 0) { + Rectangle clip = new Rectangle(bounds.getMinX(), bounds.getMinY(), + bounds.getWidth(), bounds.getHeight()); + overlayPane.setClip(clip); + } + }; + updater.listen(node.boundsInLocalProperty(), listener); + listener.changed(null, null, node.getBoundsInParent()); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/Walkthrough.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/Walkthrough.java new file mode 100644 index 00000000000..0334f0f1dc0 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/Walkthrough.java @@ -0,0 +1,157 @@ +package org.jabref.gui.walkthrough; + +import java.util.List; +import java.util.Objects; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.ReadOnlyIntegerProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.stage.Stage; + +import org.jabref.gui.walkthrough.declarative.step.WalkthroughStep; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Maintains the state of a walkthrough. + */ +public class Walkthrough { + private static final Logger LOGGER = LoggerFactory.getLogger(Walkthrough.class); + + private final IntegerProperty currentStep; + private final BooleanProperty active; + + private final List steps; + private @Nullable WalkthroughOverlay overlay; + private Stage currentStage; + + public Walkthrough(List steps) { + if (steps.isEmpty() || steps.stream().anyMatch(Objects::isNull)) { + // This throwing is acceptable, since the Walkthrough is often hardcoded and won't make the application crash + throw new IllegalArgumentException("Walkthrough must have at least one step and no null steps allowed."); + } + this.currentStep = new SimpleIntegerProperty(0); + this.active = new SimpleBooleanProperty(false); + this.steps = steps; + } + + public Walkthrough(@NonNull WalkthroughStep... steps) { + this(List.of(steps)); + } + + /** + * Gets the current step index property. + * + * @return The current step index property. + */ + public ReadOnlyIntegerProperty currentStepProperty() { + return currentStep; + } + + /** + * Starts the walkthrough from the first step. + * + * @param stage The stage to display the walkthrough on + */ + public void start(Stage stage) { + if (currentStage != stage) { + if (overlay != null) { + overlay.detachAll(); + overlay = null; + } + currentStage = stage; + overlay = new WalkthroughOverlay(stage, this); + } + + currentStep.set(0); + active.set(true); + + if (overlay == null) { + LOGGER.warn("Overlay is null after initialization, cannot display step"); + return; + } + + WalkthroughStep step = getCurrentStep(); + overlay.displayStep(step); + } + + /** + * Moves to the next step in the walkthrough. + */ + public void nextStep() { + int nextIndex = currentStep.get() + 1; + if (nextIndex >= steps.size()) { + stop(); + return; + } + + currentStep.set(nextIndex); + if (overlay == null) { + LOGGER.warn("Overlay is null, cannot display next step"); + return; + } + + WalkthroughStep step = getCurrentStep(); + overlay.displayStep(step); + } + + /** + * Moves to the previous step in the walkthrough. + */ + public void previousStep() { + int prevIndex = currentStep.get() - 1; + if (prevIndex < 0) { + return; + } + + currentStep.set(prevIndex); + if (overlay == null) { + LOGGER.warn("Overlay is null, cannot display previous step"); + return; + } + + WalkthroughStep step = getCurrentStep(); + overlay.displayStep(step); + } + + private void stop() { + if (overlay != null) { + overlay.detachAll(); + } + active.set(false); + } + + public void goToStep(int stepIndex) { + if (stepIndex < 0 || stepIndex >= steps.size()) { + LOGGER.debug("Invalid step index: {}. Valid range is 0 to {}.", stepIndex, steps.size() - 1); + return; + } + + currentStep.set(stepIndex); + if (overlay == null) { + LOGGER.warn("Overlay is null, cannot go to step {}", stepIndex); + return; + } + + WalkthroughStep step = getCurrentStep(); + overlay.displayStep(step); + } + + public @NonNull WalkthroughStep getStepAtIndex(int index) { + return steps.get(index); + } + + public void skip() { + stop(); + } + + private @NonNull WalkthroughStep getCurrentStep() { + return steps.get(currentStep.get()); + } +} + diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughAction.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughAction.java new file mode 100644 index 00000000000..99646dbf9a5 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughAction.java @@ -0,0 +1,122 @@ +package org.jabref.gui.walkthrough; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +import javafx.scene.control.ContextMenu; +import javafx.stage.Stage; + +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.preferences.PreferencesDialogView; +import org.jabref.gui.walkthrough.declarative.NavigationPredicate; +import org.jabref.gui.walkthrough.declarative.NodeResolver; +import org.jabref.gui.walkthrough.declarative.WindowResolver; +import org.jabref.gui.walkthrough.declarative.effect.HighlightEffect; +import org.jabref.gui.walkthrough.declarative.effect.MultiWindowHighlight; +import org.jabref.gui.walkthrough.declarative.effect.WindowEffect; +import org.jabref.gui.walkthrough.declarative.richtext.InfoBlock; +import org.jabref.gui.walkthrough.declarative.richtext.TextBlock; +import org.jabref.gui.walkthrough.declarative.step.PanelPosition; +import org.jabref.gui.walkthrough.declarative.step.PanelStep; +import org.jabref.gui.walkthrough.declarative.step.TooltipPosition; +import org.jabref.gui.walkthrough.declarative.step.TooltipStep; +import org.jabref.gui.walkthrough.declarative.step.WalkthroughStep; +import org.jabref.logic.l10n.Localization; + +import com.airhacks.afterburner.injection.Injector; + +public class WalkthroughAction extends SimpleCommand { + private static final Map> WALKTHROUGH_REGISTRY = Map.of("mainFileDirectory", WalkthroughAction::createMainFileDirectoryWalkthrough); + private static final Map WALKTHROUGH_CACHE = new HashMap<>(); // must be mutable to allow caching of created walkthroughs + + private final Walkthrough walkthrough; + private final Stage mainStage; + + public WalkthroughAction(String name) { + this.mainStage = Injector.instantiateModelOrService(Stage.class); + if (WALKTHROUGH_CACHE.containsKey(name)) { + this.walkthrough = WALKTHROUGH_CACHE.get(name); + } else { + Function walkthroughProvider = WALKTHROUGH_REGISTRY.get(name); + Objects.requireNonNull(walkthroughProvider, "Walkthrough not found: " + name); + this.walkthrough = walkthroughProvider.apply(mainStage); + WALKTHROUGH_CACHE.put(name, this.walkthrough); + } + } + + @Override + public void execute() { + walkthrough.start(this.mainStage); + } + + private static Walkthrough createMainFileDirectoryWalkthrough(Stage mainStage) { + WindowResolver mainResolver = () -> Optional.of(mainStage); + + WalkthroughStep step1 = TooltipStep + .builder(Localization.lang("Click on \"File\" menu")) + .resolver(NodeResolver.selector(".menu-bar .menu-button:first-child")) + .navigation(NavigationPredicate.onClick()) + .position(TooltipPosition.BOTTOM) + .highlight(HighlightEffect.BACKDROP_HIGHLIGHT) + .build(); + + WalkthroughStep step2 = TooltipStep + .builder(Localization.lang("Click on \"Preferences\"")) + .resolver(NodeResolver.menuItem("Preferences")) + .navigation(NavigationPredicate.onClick()) + .position(TooltipPosition.RIGHT) + .activeWindow(WindowResolver.clazz(ContextMenu.class)) + .highlight(new MultiWindowHighlight( + new WindowEffect(HighlightEffect.ANIMATED_PULSE), + new WindowEffect(mainResolver, HighlightEffect.FULL_SCREEN_DARKEN) + )) + .build(); + + MultiWindowHighlight preferenceHighlight = new MultiWindowHighlight( + new WindowEffect(HighlightEffect.BACKDROP_HIGHLIGHT), + new WindowEffect(mainResolver, HighlightEffect.FULL_SCREEN_DARKEN) + ); + WalkthroughStep step3 = TooltipStep + .builder(Localization.lang("Select the \"Linked files\" tab")) + .content(new TextBlock(Localization.lang("This section manages how JabRef handles your PDF files and other documents."))) + .width(400) + .resolver(NodeResolver.predicate(node -> + node.getStyleClass().contains("list-cell") && + node.toString().contains(Localization.lang("Linked files")))) + .navigation(NavigationPredicate.onClick()) + .position(TooltipPosition.AUTO) + .activeWindow(WindowResolver.title(PreferencesDialogView.DIALOG_TITLE)) + .highlight(preferenceHighlight) + .build(); + + WalkthroughStep step4 = TooltipStep + .builder(Localization.lang("Enable \"Main file directory\" option")) + .content(new TextBlock(Localization.lang("Choose this option to tell JabRef where your research files are stored. This makes it easy to attach PDFs and other documents to your bibliography entries. You can browse to select your preferred folder in the next step."))) + .width(400) + .resolver(NodeResolver.fxId("useMainFileDirectory")) + .navigation(NavigationPredicate.onClick()) + .position(TooltipPosition.AUTO) + .highlight(preferenceHighlight) + .activeWindow(WindowResolver.title(PreferencesDialogView.DIALOG_TITLE)) + .build(); + + WalkthroughStep step5 = PanelStep + .builder(Localization.lang("Click \"Save\" to save changes")) + .content( + new TextBlock(Localization.lang("Congratulations. Your main file directory is now configured. JabRef will use this location to automatically find and organize your research documents.")), + new InfoBlock(Localization.lang("Additional information on main file directory can be found in https://docs.jabref.org/v5/finding-sorting-and-cleaning-entries/filelinks")) + ) + .height(180) + .resolver(NodeResolver.predicate(node -> node.getStyleClass().contains("button") && node.toString().contains(Localization.lang("Save")))) + .navigation(NavigationPredicate.onClick()) + .position(PanelPosition.TOP) + .highlight(preferenceHighlight) + .activeWindow(WindowResolver.title(PreferencesDialogView.DIALOG_TITLE)) + .build(); + + return new Walkthrough(step1, step2, step3, step4, step5); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughHighlighter.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughHighlighter.java new file mode 100644 index 00000000000..ade463972d3 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughHighlighter.java @@ -0,0 +1,139 @@ +package org.jabref.gui.walkthrough; + +import java.util.HashMap; +import java.util.Map; + +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.layout.Pane; +import javafx.stage.Window; + +import org.jabref.gui.walkthrough.declarative.WindowResolver; +import org.jabref.gui.walkthrough.declarative.effect.HighlightEffect; +import org.jabref.gui.walkthrough.declarative.effect.MultiWindowHighlight; +import org.jabref.gui.walkthrough.effects.BackdropHighlight; +import org.jabref.gui.walkthrough.effects.FullScreenDarken; +import org.jabref.gui.walkthrough.effects.PulseAnimateIndicator; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Manages highlight effects across multiple windows for walkthrough steps. + */ +public class WalkthroughHighlighter { + // backdropHighlights, pulseIndicators, and fullScreenDarkens represents the current state of walkthrough GUI effects + private final Map backdropHighlights = new HashMap<>(); + private final Map pulseIndicators = new HashMap<>(); + private final Map fullScreenDarkens = new HashMap<>(); + + /** + * Applies the specified highlight configuration. + * + * @param config The highlight configuration to apply. Default to + * BackdropHighlight on the primary windows if null. + * @param fallbackWindow The primary scene to apply the highlight to. + * @param fallbackTarget The fallback target node to use if no highlight + * configuration is provided. + */ + public void applyHighlight(@Nullable MultiWindowHighlight config, @NonNull Scene fallbackWindow, + @Nullable Node fallbackTarget) { + detachAll(); + + if (config == null) { + if (fallbackTarget != null) { + applyBackdropHighlight(fallbackWindow.getWindow(), fallbackTarget); + } + return; + } + + if (config.windowEffects().isEmpty() && config.fallbackEffect().isPresent()) { + applyEffect(fallbackWindow.getWindow(), config.fallbackEffect().get(), fallbackTarget); + return; + } + + config.windowEffects().forEach(effect -> { + Window window = effect.windowResolver().flatMap(WindowResolver::resolve).orElse(fallbackWindow.getWindow()); + Node targetNode = effect + .targetNodeResolver() + .flatMap(resolver -> resolver.resolve(window.getScene() != null ? window.getScene() : fallbackWindow)) + .orElse(fallbackTarget); + applyEffect(window, effect.effect(), targetNode); + }); + } + + /** + * Detaches all active highlight effects. + */ + public void detachAll() { + backdropHighlights.values().forEach(BackdropHighlight::detach); + backdropHighlights.clear(); + + pulseIndicators.values().forEach(PulseAnimateIndicator::detach); + pulseIndicators.clear(); + + fullScreenDarkens.values().forEach(FullScreenDarken::detach); + fullScreenDarkens.clear(); + } + + private void applyEffect(@NonNull Window window, @NonNull HighlightEffect effect, @Nullable Node targetNode) { + switch (effect) { + case BACKDROP_HIGHLIGHT -> { + if (targetNode != null) { + applyBackdropHighlight(window, targetNode); + } + } + case ANIMATED_PULSE -> { + if (targetNode != null) { + applyPulseAnimation(window, targetNode); + } + } + case FULL_SCREEN_DARKEN -> + applyFullScreenDarken(window); + case NONE -> { + backdropHighlights.computeIfPresent(window, (_, highlight) -> { + highlight.detach(); + return null; + }); + pulseIndicators.computeIfPresent(window, (_, indicator) -> { + indicator.detach(); + return null; + }); + fullScreenDarkens.computeIfPresent(window, (_, darken) -> { + darken.detach(); + return null; + }); + } + } + } + + private void applyBackdropHighlight(@NonNull Window window, @NonNull Node targetNode) { + Scene scene = window.getScene(); + if (scene == null || !(scene.getRoot() instanceof Pane pane)) { + return; + } + + BackdropHighlight backdrop = backdropHighlights.computeIfAbsent(window, _ -> new BackdropHighlight(pane)); + backdrop.attach(targetNode); + } + + private void applyPulseAnimation(@NonNull Window window, @NonNull Node targetNode) { + Scene scene = window.getScene(); + if (scene == null || !(scene.getRoot() instanceof Pane pane)) { + return; + } + + PulseAnimateIndicator pulse = pulseIndicators.computeIfAbsent(window, _ -> new PulseAnimateIndicator(pane)); + pulse.attach(targetNode); + } + + private void applyFullScreenDarken(@NonNull Window window) { + Scene scene = window.getScene(); + if (scene == null || !(scene.getRoot() instanceof Pane pane)) { + return; + } + + FullScreenDarken fullDarken = fullScreenDarkens.computeIfAbsent(window, _ -> new FullScreenDarken(pane)); + fullDarken.attach(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughOverlay.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughOverlay.java new file mode 100644 index 00000000000..88aa7b6fd09 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughOverlay.java @@ -0,0 +1,143 @@ +package org.jabref.gui.walkthrough; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.stage.Stage; +import javafx.stage.Window; +import javafx.util.Duration; + +import org.jabref.gui.walkthrough.declarative.WindowResolver; +import org.jabref.gui.walkthrough.declarative.step.WalkthroughStep; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class WalkthroughOverlay { + private static final Logger LOGGER = LoggerFactory.getLogger(WalkthroughOverlay.class); + + private final Map overlays = new HashMap<>(); + private final Stage stage; + private final WalkthroughHighlighter walkthroughHighlighter; + private final Walkthrough walkthrough; + private @Nullable Timeline nodePollingTimeline; + + public WalkthroughOverlay(Stage stage, Walkthrough walkthrough) { + this.stage = stage; + this.walkthrough = walkthrough; + this.walkthroughHighlighter = new WalkthroughHighlighter(); + } + + /** + * Displays the specified walkthrough step in the appropriate window. + */ + public void displayStep(@NonNull WalkthroughStep step) { + overlays.values().forEach(SingleWindowWalkthroughOverlay::hide); + Window window = step.activeWindowResolver().flatMap(WindowResolver::resolve).orElse(stage); + Scene scene = window.getScene(); + + Optional targetNode = step.resolver().flatMap(resolver -> resolver.resolve(scene)); + + if (step.resolver().isPresent()) { + startNodePolling(step, window, targetNode.orElse(null)); + } else { + walkthroughHighlighter.applyHighlight(step.highlight().orElse(null), scene, null); + displayStep(step, window, null); + } + } + + public void detachAll() { + stopNodePolling(); + walkthroughHighlighter.detachAll(); + overlays.values().forEach(SingleWindowWalkthroughOverlay::detach); + overlays.clear(); + } + + private void tryRevertToPreviousResolvableStep() { + LOGGER.info("Attempting to revert to previous resolvable step"); + + int currentIndex = walkthrough.currentStepProperty().get(); + + for (int i = currentIndex - 1; i >= 0; i--) { + WalkthroughStep previousStep = walkthrough.getStepAtIndex(i); + Window activeWindow = previousStep.activeWindowResolver().flatMap(WindowResolver::resolve).orElse(stage); + Scene scene = activeWindow.getScene(); + if (scene != null && (previousStep.resolver().isEmpty() || previousStep.resolver().get().resolve(scene).isPresent())) { + LOGGER.info("Reverting to step {} from step {}", i, currentIndex); + walkthrough.goToStep(i); + return; + } + } + + LOGGER.warn("No previous resolvable step found, staying at current step"); + } + + private void displayStep(WalkthroughStep step, Window window, @Nullable Node node) { + SingleWindowWalkthroughOverlay overlay = getOrCreateOverlay(window); + overlay.displayStep(step, node, this::stopNodePolling, walkthrough); + } + + private void startNodePolling(WalkthroughStep step, Window window, @Nullable Node node) { + stopNodePolling(); + + AtomicBoolean nodeEverResolved = new AtomicBoolean(node != null); + + Scene initialScene = window.getScene(); + + walkthroughHighlighter.applyHighlight(step.highlight().orElse(null), initialScene, node); + displayStep(step, window, node); + + LOGGER.info("Starting continuous node polling for step: {}", step.title()); + + nodePollingTimeline = new Timeline(new KeyFrame(Duration.millis(500), _ -> { + Scene scene = window.getScene(); + if (scene == null) { + return; + } + + step.resolver().flatMap(resolver -> resolver.resolve(scene)).ifPresentOrElse( + (currentNode) -> { + if (!nodeEverResolved.get()) { + LOGGER.info("Target node found for step: {}, updating display", step.title()); + walkthroughHighlighter.applyHighlight(step.highlight().orElse(null), scene, currentNode); + displayStep(step, window, currentNode); + nodeEverResolved.set(true); + } + }, + () -> { + if (!nodeEverResolved.get()) { + return; + } + + LOGGER.info("Node disappeared for step: {}, auto-falling back", step.title()); + stopNodePolling(); + tryRevertToPreviousResolvableStep(); + } + ); + })); + + nodePollingTimeline.setCycleCount(Timeline.INDEFINITE); + nodePollingTimeline.play(); + } + + private void stopNodePolling() { + LOGGER.info("Stopping node polling for step."); + if (nodePollingTimeline == null) { + return; + } + nodePollingTimeline.stop(); + nodePollingTimeline = null; + } + + private SingleWindowWalkthroughOverlay getOrCreateOverlay(Window window) { + return overlays.computeIfAbsent(window, SingleWindowWalkthroughOverlay::new); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughRenderer.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughRenderer.java new file mode 100644 index 00000000000..bf9a7939ff6 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughRenderer.java @@ -0,0 +1,190 @@ +package org.jabref.gui.walkthrough; + +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; + +import org.jabref.gui.icon.IconTheme; +import org.jabref.gui.icon.JabRefIconView; +import org.jabref.gui.walkthrough.declarative.richtext.ArbitraryJFXBlock; +import org.jabref.gui.walkthrough.declarative.richtext.InfoBlock; +import org.jabref.gui.walkthrough.declarative.richtext.TextBlock; +import org.jabref.gui.walkthrough.declarative.step.PanelPosition; +import org.jabref.gui.walkthrough.declarative.step.PanelStep; +import org.jabref.gui.walkthrough.declarative.step.TooltipStep; +import org.jabref.gui.walkthrough.declarative.step.WalkthroughStep; +import org.jabref.logic.l10n.Localization; + +/** + * Renders the walkthrough steps and content blocks into JavaFX Nodes. + */ +public class WalkthroughRenderer { + /** + * Renders a tooltip step into a JavaFX Node. + * + * @param step The tooltip step to render + * @param walkthrough The walkthrough context for navigation + * @param beforeNavigate Runnable to execute before any navigation action + * @return The rendered tooltip content Node + */ + public Node render(TooltipStep step, Walkthrough walkthrough, Runnable beforeNavigate) { + VBox tooltip = new VBox(); + tooltip.getStyleClass().addAll("root", "walkthrough-tooltip-content-container"); + + Label titleLabel = new Label(Localization.lang(step.title())); + titleLabel.getStyleClass().add("walkthrough-tooltip-title"); + + VBox contentContainer = makeContent(step, walkthrough, beforeNavigate); + contentContainer.getStyleClass().add("walkthrough-tooltip-content"); + VBox.setVgrow(contentContainer, Priority.ALWAYS); + + HBox actionsContainer = makeActions(step, walkthrough, beforeNavigate); + actionsContainer.getStyleClass().add("walkthrough-tooltip-actions"); + + step.height().ifPresent(height -> { + tooltip.setPrefHeight(height); + tooltip.setMaxHeight(height); + tooltip.setMinHeight(height); + }); + step.width().ifPresent(width -> { + tooltip.setPrefWidth(width); + tooltip.setMaxWidth(width); + tooltip.setMinWidth(width); + }); + + tooltip.getChildren().addAll(titleLabel, contentContainer, actionsContainer); + return tooltip; + } + + /** + * Renders a panel step into a JavaFX Node. + * + * @param step The panel step to render + * @param walkthrough The walkthrough context for navigation + * @param beforeNavigate Runnable to execute before any navigation action + * @return The rendered panel Node + */ + public Node render(PanelStep step, Walkthrough walkthrough, Runnable beforeNavigate) { + VBox panel = makePanel(); + configurePanelSize(panel, step); + + Label titleLabel = new Label(Localization.lang(step.title())); + titleLabel.getStyleClass().add("walkthrough-title"); + + VBox contentContainer = makeContent(step, walkthrough, beforeNavigate); + HBox actionsContainer = makeActions(step, walkthrough, beforeNavigate); + + panel.getChildren().addAll(titleLabel, contentContainer, actionsContainer); + return panel; + } + + private void configurePanelSize(VBox panel, PanelStep step) { + boolean isVertical = step.position() == PanelPosition.LEFT || step.position() == PanelPosition.RIGHT; + + if (isVertical) { + panel.getStyleClass().add("walkthrough-side-panel-vertical"); + VBox.setVgrow(panel, Priority.ALWAYS); + panel.setMaxHeight(Double.MAX_VALUE); + step.width().ifPresent(width -> { + panel.setPrefWidth(width); + panel.setMaxWidth(width); + panel.setMinWidth(width); + }); + } else if (step.position() == PanelPosition.TOP || step.position() == PanelPosition.BOTTOM) { + panel.getStyleClass().add("walkthrough-side-panel-horizontal"); + HBox.setHgrow(panel, Priority.ALWAYS); + panel.setMaxWidth(Double.MAX_VALUE); + step.height().ifPresent(height -> { + panel.setPrefHeight(height); + panel.setMaxHeight(height); + panel.setMinHeight(height); + }); + } + } + + private Node render(ArbitraryJFXBlock block, Walkthrough walkthrough, Runnable beforeNavigate) { + return block.componentFactory().apply(walkthrough, beforeNavigate); + } + + private Node render(TextBlock textBlock) { + Label textLabel = new Label(Localization.lang(textBlock.text())); + textLabel.getStyleClass().add("walkthrough-text-content"); + return textLabel; + } + + private Node render(InfoBlock infoBlock) { + HBox infoContainer = new HBox(); + infoContainer.getStyleClass().add("walkthrough-info-container"); + JabRefIconView icon = new JabRefIconView(IconTheme.JabRefIcons.INTEGRITY_INFO); + Label infoLabel = new Label(Localization.lang(infoBlock.text())); + HBox.setHgrow(infoLabel, Priority.ALWAYS); + infoContainer.getChildren().addAll(icon, infoLabel); + return infoContainer; + } + + private VBox makePanel() { + VBox container = new VBox(); + container.getStyleClass().add("walkthrough-panel"); + return container; + } + + private HBox makeActions(WalkthroughStep step, Walkthrough walkthrough, Runnable beforeNavigate) { + HBox actions = new HBox(); + actions.setAlignment(Pos.CENTER_LEFT); + actions.getStyleClass().add("walkthrough-actions"); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + step.backButtonText() + .ifPresent(text -> + actions.getChildren() + .add(makeButton(text, "walkthrough-back-button", beforeNavigate, walkthrough::previousStep))); + + HBox rightActions = new HBox(); + rightActions.setAlignment(Pos.CENTER_RIGHT); + rightActions.getStyleClass().add("walkthrough-right-actions"); + + step.skipButtonText() + .ifPresent(text -> + rightActions.getChildren() + .add(makeButton(text, "walkthrough-skip-button", beforeNavigate, walkthrough::skip))); + step.continueButtonText() + .ifPresent(text -> + rightActions.getChildren() + .add(makeButton(text, "walkthrough-continue-button", beforeNavigate, walkthrough::nextStep))); + actions.getChildren().addAll(spacer, rightActions); + return actions; + } + + private VBox makeContent(WalkthroughStep step, Walkthrough walkthrough, Runnable beforeNavigate) { + VBox contentBox = new VBox(); + contentBox.getStyleClass().add("walkthrough-content"); + contentBox.getChildren().addAll(step.content().stream().map(block -> + switch (block) { + case TextBlock textBlock -> + render(textBlock); + case InfoBlock infoBlock -> + render(infoBlock); + case ArbitraryJFXBlock arbitraryBlock -> + render(arbitraryBlock, walkthrough, beforeNavigate); + } + ).toArray(Node[]::new)); + return contentBox; + } + + private Button makeButton(String text, String styleClass, Runnable beforeNavigate, Runnable navigationAction) { + Button button = new Button(Localization.lang(text)); + button.getStyleClass().add(styleClass); + button.setOnAction(_ -> { + beforeNavigate.run(); + navigationAction.run(); + }); + return button; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughUpdater.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughUpdater.java new file mode 100644 index 00000000000..e4b17957f0d --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughUpdater.java @@ -0,0 +1,158 @@ +package org.jabref.gui.walkthrough; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.beans.InvalidationListener; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import javafx.event.EventHandler; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.control.ListView; +import javafx.scene.control.ScrollPane; +import javafx.scene.input.ScrollEvent; +import javafx.stage.Window; +import javafx.util.Duration; + +import com.sun.javafx.scene.NodeHelper; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Managing listeners and automatic cleanup in walkthrough + */ +public class WalkthroughUpdater { + private final List cleanupTasks = new ArrayList<>(); // has to be mutable + private final Timeline updateTimeline = new Timeline(); + + /** + * Adds a cleanup task to be executed when cleanup() is called. + */ + public void addCleanupTask(Runnable task) { + cleanupTasks.add(task); + } + + public void listen(@NonNull ObservableValue property, @NonNull InvalidationListener listener) { + property.addListener(listener); + cleanupTasks.add(() -> property.removeListener(listener)); + } + + public void listen(@NonNull ObservableValue property, @NonNull ChangeListener listener) { + property.addListener(listener); + cleanupTasks.add(() -> property.removeListener(listener)); + } + + /** + * Handles scroll events by executing the provided handler and scheduling + * follow-up updates using a timeline. + */ + public void handleScrollEvent(@NonNull Runnable handler) { + handler.run(); + // Schedule updates at 50ms intervals for up to 250ms to handle scroll overshoot + // debounced to prevent excessive updates + for (int i = 0; i <= Math.min(5, 5 - updateTimeline.getKeyFrames().size()); i++) { + KeyFrame keyFrame = new KeyFrame(Duration.millis(i * 50), _ -> handler.run()); + updateTimeline.getKeyFrames().add(keyFrame); + } + updateTimeline.play(); + } + + /** + * Sets up listeners for a node + */ + public void setupNodeListeners(@NonNull Node node, @NonNull Runnable updateHandler) { + InvalidationListener updateListener = _ -> updateHandler.run(); + listen(node.boundsInLocalProperty(), updateListener); + listen(node.localToSceneTransformProperty(), updateListener); + listen(node.boundsInParentProperty(), updateListener); + listen(node.visibleProperty(), updateListener); + listen(node.layoutBoundsProperty(), updateListener); + listen(node.managedProperty(), updateListener); + setupScrollContainerListeners(node, updateHandler); + setupSceneListeners(node, updateHandler); + } + + /** + * Sets up listeners for scroll containers (ScrollPane, ListView) + */ + public void setupScrollContainerListeners(@NonNull Node node, @NonNull Runnable updateHandler) { + EventHandler scrollHandler = _ -> handleScrollEvent(updateHandler); + + Stream.iterate(node, Objects::nonNull, Node::getParent) + .filter(p -> p instanceof ScrollPane || p instanceof ListView) + .findFirst() + .ifPresent(parent -> { + if (parent instanceof ScrollPane scrollPane) { + ChangeListener scrollListener = (_, _, _) -> handleScrollEvent(updateHandler); + listen(scrollPane.vvalueProperty(), scrollListener); + } else if (parent instanceof ListView listView) { + listView.addEventFilter(ScrollEvent.ANY, scrollHandler); + cleanupTasks.add(() -> listView.removeEventFilter(ScrollEvent.ANY, scrollHandler)); + listen(listView.focusModelProperty(), _ -> updateHandler.run()); + } + }); + + node.addEventFilter(ScrollEvent.ANY, scrollHandler); + cleanupTasks.add(() -> node.removeEventFilter(ScrollEvent.ANY, scrollHandler)); + } + + /** + * Sets up listeners for scene and window property changes + */ + public void setupSceneListeners(@NonNull Node node, @NonNull Runnable updateHandler) { + ChangeListener sceneListener = (_, _, scene) -> { + updateHandler.run(); + if (scene == null) { + return; + } + listen(scene.widthProperty(), _ -> updateHandler.run()); + listen(scene.heightProperty(), _ -> updateHandler.run()); + + if (scene.getWindow() != null) { + setupWindowListeners(scene.getWindow(), updateHandler); + } + }; + + listen(node.sceneProperty(), sceneListener); + if (node.getScene() != null) { + sceneListener.changed(null, null, node.getScene()); + } + } + + /** + * Sets up listeners for window property changes. + */ + public void setupWindowListeners(@NonNull Window window, @NonNull Runnable updateHandler) { + listen(window.widthProperty(), _ -> updateHandler.run()); + listen(window.heightProperty(), _ -> updateHandler.run()); + listen(window.showingProperty(), _ -> updateHandler.run()); + } + + /** + * Check if a node is visible in the scene graph + */ + public static boolean isNodeVisible(@Nullable Node node) { + return node != null && NodeHelper.isTreeVisible(node); + } + + /** + * Utility method to check if a node cannot be positioned + */ + public static boolean cannotPositionNode(@Nullable Node node) { + return node == null || node.getScene() == null || !isNodeVisible(node) || node.getBoundsInLocal().isEmpty(); + } + + /** + * Cleans up all registered listeners and stops any active timelines + */ + public void cleanup() { + updateTimeline.stop(); + cleanupTasks.forEach(Runnable::run); + cleanupTasks.clear(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/NavigationPredicate.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/NavigationPredicate.java new file mode 100644 index 00000000000..ed4e4e7cd05 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/NavigationPredicate.java @@ -0,0 +1,212 @@ +package org.jabref.gui.walkthrough.declarative; + +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; + +import javafx.application.Platform; +import javafx.beans.value.ChangeListener; +import javafx.collections.ListChangeListener; +import javafx.event.ActionEvent; +import javafx.event.Event; +import javafx.event.EventHandler; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.ButtonBase; +import javafx.scene.control.MenuItem; +import javafx.scene.control.TextInputControl; +import javafx.scene.input.MouseEvent; +import javafx.stage.Window; + +import org.jabref.gui.frame.MainMenu; + +import com.sun.javafx.scene.control.ContextMenuContent; +import org.jspecify.annotations.NonNull; + +/** + * Defines a predicate for when navigation should occur on a target node. + */ +@FunctionalInterface +public interface NavigationPredicate { + long HANDLER_TIMEOUT_MS = 1000; + + /** + * Attaches the navigation listeners to the target node. + * + * @param targetNode the node to attach the listeners to + * @param beforeNavigate the runnable to execute before navigation + * @param onNavigate the runnable to execute when navigation occurs + * @return a runnable to clean up the listeners + */ + Runnable attachListeners(@NonNull Node targetNode, Runnable beforeNavigate, Runnable onNavigate); + + static NavigationPredicate onClick() { + return (targetNode, beforeNavigate, onNavigate) -> { + EventHandler onClicked = targetNode.getOnMouseClicked(); + targetNode.setOnMouseClicked(decorate(beforeNavigate, onClicked, onNavigate)); + + Optional item = resolveMenuItem(targetNode); + if (item.isPresent()) { + MenuItem menuItem = item.get(); + // Note MenuItem doesn't extend Node, so the duplication between MenuItem and ButtonBase cannot be removed + EventHandler onAction = menuItem.getOnAction(); + EventHandler decoratedAction = decorate(beforeNavigate, onAction, onNavigate); + menuItem.setOnAction(decoratedAction); + menuItem.addEventFilter(ActionEvent.ACTION, decoratedAction); + + return () -> { + targetNode.setOnMouseClicked(onClicked); + menuItem.setOnAction(onAction); + menuItem.removeEventFilter(ActionEvent.ACTION, decoratedAction); + }; + } + + if (targetNode instanceof ButtonBase button) { + EventHandler onAction = button.getOnAction(); + EventHandler decoratedAction = decorate(beforeNavigate, onAction, onNavigate); + + button.setOnAction(decoratedAction); + button.addEventFilter(ActionEvent.ACTION, decoratedAction); + + return () -> { + targetNode.setOnMouseClicked(onClicked); + button.setOnAction(onAction); + button.removeEventFilter(ActionEvent.ACTION, decoratedAction); + }; + } + + return () -> targetNode.setOnMouseClicked(onClicked); + }; + } + + static NavigationPredicate onHover() { + return (targetNode, beforeNavigate, onNavigate) -> { + EventHandler onEnter = targetNode.getOnMouseEntered(); + targetNode.setOnMouseEntered(decorate(beforeNavigate, onEnter, onNavigate)); + + Optional item = resolveMenuItem(targetNode); + if (item.isPresent()) { + throw new IllegalArgumentException("onHover cannot be used with MenuItems"); + } + + return () -> targetNode.setOnMouseEntered(onEnter); + }; + } + + static NavigationPredicate onTextInput() { + return (targetNode, beforeNavigate, onNavigate) -> { + if (targetNode instanceof TextInputControl textInput) { + ChangeListener listener = (_, _, newText) -> { + if (!newText.trim().isEmpty()) { + beforeNavigate.run(); + onNavigate.run(); + } + }; + textInput.textProperty().addListener(listener); + return () -> textInput.textProperty().removeListener(listener); + } + throw new IllegalArgumentException("onTextInput can only be used with TextInputControl"); + }; + } + + static NavigationPredicate manual() { + return (_, _, _) -> () -> { + }; + } + + static NavigationPredicate auto() { + return (_, _, onNavigate) -> { + onNavigate.run(); + return () -> { + }; + }; + } + + private static Optional resolveMenuItem(Node node) { + if (!(node instanceof ContextMenuContent) + && Stream.iterate(node.getParent(), Objects::nonNull, Parent::getParent) + .noneMatch(ContextMenuContent.class::isInstance)) { + return Optional.empty(); + } + + return Window.getWindows().stream() + .map(Window::getScene) + .filter(Objects::nonNull) + .map(scene -> scene.lookup(".mainMenu")) + .filter(MainMenu.class::isInstance) + .map(MainMenu.class::cast) + .flatMap(menu -> menu.getMenus().stream()) + .flatMap(topLevelMenu -> topLevelMenu.getItems().stream()) + .filter(menuItem -> Optional.ofNullable(menuItem.getGraphic()) + .map(graphic -> graphic.equals(node) + || Stream.iterate(graphic, Objects::nonNull, Node::getParent) + .anyMatch(cm -> cm.equals(node))) + .orElse(false)) + .findFirst(); + } + + static EventHandler decorate( + Runnable beforeNavigate, + EventHandler originalHandler, + Runnable onNavigate) { + return event -> navigate(beforeNavigate, originalHandler, event, onNavigate); + } + + static void navigate( + Runnable beforeNavigate, + EventHandler originalHandler, + T event, + Runnable onNavigate) { + + event.consume(); + beforeNavigate.run(); + + CompletableFuture handlerFuture = new CompletableFuture<>(); + + if (originalHandler != null) { + Platform.runLater(() -> { + try { + originalHandler.handle(event); + } finally { + handlerFuture.complete(null); + } + }); + } else { + handlerFuture.complete(null); + } + + CompletableFuture windowFuture = new CompletableFuture<>(); + + ListChangeListener listener = new ListChangeListener<>() { + @Override + public void onChanged(Change change) { + while (change.next()) { + if (change.wasAdded()) { + Window.getWindows().removeListener(this); + windowFuture.complete(null); + return; + } + } + } + }; + + Platform.runLater(() -> Window.getWindows().addListener(listener)); + + CompletableFuture timeoutFuture = CompletableFuture.runAsync(() -> { + try { + Thread.sleep(HANDLER_TIMEOUT_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + CompletableFuture.anyOf(handlerFuture, windowFuture, timeoutFuture) + .whenComplete((_, _) -> { + Platform.runLater(onNavigate); + timeoutFuture.cancel(true); + Platform.runLater(() -> Window.getWindows().removeListener(listener)); + windowFuture.cancel(true); + }); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/NodeResolver.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/NodeResolver.java new file mode 100644 index 00000000000..3f12bd49b7b --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/NodeResolver.java @@ -0,0 +1,146 @@ +package org.jabref.gui.walkthrough.declarative; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ContextMenu; + +import org.jabref.gui.actions.StandardActions; +import org.jabref.logic.l10n.Localization; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Resolves nodes from a Scene + */ +@FunctionalInterface +public interface NodeResolver { + /** + * Resolves a node from the given scene. + * + * @param scene the scene to search in + * @return an optional containing the found node, or empty if not found + */ + Optional resolve(@NonNull Scene scene); + + /** + * Creates a resolver that finds a node by CSS selector. + * + * @param selector the CSS selector to find the node + * @return a resolver that finds the node by selector + */ + static NodeResolver selector(@NonNull String selector) { + return scene -> Optional.ofNullable(scene.lookup(selector)); + } + + /** + * Creates a resolver that finds a node by its fx:id. + * + * @param fxId the fx:id of the node + * @return a resolver that finds the node by fx:id + */ + static NodeResolver fxId(@NonNull String fxId) { + return scene -> Optional.ofNullable(scene.lookup("#" + fxId)); + } + + /** + * Creates a resolver that finds a node by a predicate. + * + * @param predicate the predicate to match the node + * @return a resolver that finds the node matching the predicate + */ + static NodeResolver predicate(@NonNull Predicate predicate) { + return scene -> Optional.ofNullable(findNode(scene.getRoot(), predicate)); + } + + /** + * Creates a resolver that finds a button by its StandardAction. + * + * @param action the StandardAction associated with the button + * @return a resolver that finds the button by action + */ + static NodeResolver action(@NonNull StandardActions action) { + return scene -> Optional.ofNullable(findNodeByAction(scene, action)); + } + + /** + * Creates a resolver that finds a menu item by its language key. + * + * @param key the language key of the menu item + * @return a resolver that finds the menu item by language key + */ + static NodeResolver menuItem(@NonNull String key) { + return scene -> { + if (!(scene.getWindow() instanceof ContextMenu menu)) { + return Optional.empty(); + } + + if (!menu.isShowing()) { + return Optional.empty(); + } + + return menu.getItems().stream() + .filter(item -> Optional + .ofNullable(item.getText()) + .map(str -> str.contains(Localization.lang(key))) + .orElse(false)) + .flatMap(item -> Stream + .iterate(item.getGraphic(), Objects::nonNull, Node::getParent) + .filter(node -> node.getStyleClass().contains("menu-item")) + .findFirst().stream() + ).findFirst(); + }; + } + + @Nullable + private static Node findNodeByAction(@NonNull Scene scene, @NonNull StandardActions action) { + return findNode(scene.getRoot(), node -> { + if (node instanceof Button button) { + if (button.getTooltip() != null) { + String tooltipText = button.getTooltip().getText(); + if (tooltipText != null && tooltipText.equals(action.getText())) { + return true; + } + } + + if (button.getStyleClass().contains("icon-button")) { + String actionText = action.getText(); + if (button.getTooltip() != null && button.getTooltip().getText() != null) { + String tooltipText = button.getTooltip().getText(); + if (tooltipText.startsWith(actionText) || tooltipText.contains(actionText)) { + return true; + } + } + + return button.getText() != null && button.getText().equals(actionText); + } + } + return false; + }); + } + + @Nullable + private static Node findNode(@NonNull Node root, @NonNull Predicate predicate) { + if (predicate.test(root)) { + return root; + } + + if (root instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + Node result = findNode(child, predicate); + if (result != null) { + return result; + } + } + } + + return null; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/WindowResolver.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/WindowResolver.java new file mode 100644 index 00000000000..8a87f16043e --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/WindowResolver.java @@ -0,0 +1,52 @@ +package org.jabref.gui.walkthrough.declarative; + +import java.util.Optional; + +import javafx.stage.Stage; +import javafx.stage.Window; + +import org.jabref.logic.l10n.Localization; + +import org.jspecify.annotations.NonNull; + +/** + * Resolves windows using various strategies. + */ +@FunctionalInterface +public interface WindowResolver { + /** + * Resolves a window. + * + * @return an optional containing the found window, or empty if not found + */ + Optional resolve(); + + /** + * Creates a resolver that finds a window by its title. + * + * @param key the language key of the window title + * @return a resolver that finds the window by title + */ + static WindowResolver title(@NonNull String key) { + return () -> Window.getWindows().stream() + .filter(Window::isShowing) + .filter(Stage.class::isInstance) + .map(Stage.class::cast) + .filter(stage -> stage.getTitle().contains(Localization.lang(key))) + .map(Window.class::cast) + .findFirst(); + } + + /** + * Create a resolver that finds a window by its class. + * + * @param clazz the class of the window + * @return a resolver that finds the window by class + */ + static WindowResolver clazz(@NonNull Class clazz) { + return () -> Window.getWindows().stream() + .filter(clazz::isInstance) + .filter(Window::isShowing) + .findFirst(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/effect/HighlightEffect.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/effect/HighlightEffect.java new file mode 100644 index 00000000000..6cb59a6db47 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/effect/HighlightEffect.java @@ -0,0 +1,23 @@ +package org.jabref.gui.walkthrough.declarative.effect; + +public enum HighlightEffect { + /** + * See {@link org.jabref.gui.walkthrough.effects.BackdropHighlight} + */ + BACKDROP_HIGHLIGHT, + + /** + * See {@link org.jabref.gui.walkthrough.effects.PulseAnimateIndicator} + */ + ANIMATED_PULSE, + + /** + * See {@link org.jabref.gui.walkthrough.effects.FullScreenDarken} + */ + FULL_SCREEN_DARKEN, + + /** + * No highlight effect is applied. + */ + NONE +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/effect/MultiWindowHighlight.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/effect/MultiWindowHighlight.java new file mode 100644 index 00000000000..0f6ff713c80 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/effect/MultiWindowHighlight.java @@ -0,0 +1,27 @@ +package org.jabref.gui.walkthrough.declarative.effect; + +import java.util.List; +import java.util.Optional; + +import org.jspecify.annotations.NonNull; + +public record MultiWindowHighlight( + @NonNull List windowEffects, + Optional fallbackEffect +) { + public MultiWindowHighlight(WindowEffect windowEffect) { + this(windowEffect, HighlightEffect.FULL_SCREEN_DARKEN); + } + + public MultiWindowHighlight(WindowEffect windowEffect, HighlightEffect fallback) { + this(List.of(windowEffect), Optional.of(fallback)); + } + + public MultiWindowHighlight(WindowEffect... windowEffects) { + this(HighlightEffect.FULL_SCREEN_DARKEN, windowEffects); + } + + public MultiWindowHighlight(HighlightEffect fallback, WindowEffect... windowEffects) { + this(List.of(windowEffects), Optional.of(fallback)); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/effect/WindowEffect.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/effect/WindowEffect.java new file mode 100644 index 00000000000..3fb5df99dab --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/effect/WindowEffect.java @@ -0,0 +1,26 @@ +package org.jabref.gui.walkthrough.declarative.effect; + +import java.util.Optional; + +import org.jabref.gui.walkthrough.declarative.NodeResolver; +import org.jabref.gui.walkthrough.declarative.WindowResolver; + +import org.jspecify.annotations.NonNull; + +public record WindowEffect( + @NonNull Optional windowResolver, + @NonNull HighlightEffect effect, + @NonNull Optional targetNodeResolver +) { + public WindowEffect(HighlightEffect effect) { + this(Optional.empty(), effect, Optional.empty()); + } + + public WindowEffect(WindowResolver windowResolver, HighlightEffect effect) { + this(Optional.of(windowResolver), effect, Optional.empty()); + } + + public WindowEffect(WindowResolver windowResolver, HighlightEffect effect, NodeResolver targetNodeResolver) { + this(Optional.of(windowResolver), effect, Optional.of(targetNodeResolver)); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/ArbitraryJFXBlock.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/ArbitraryJFXBlock.java new file mode 100644 index 00000000000..77a76bfec4a --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/ArbitraryJFXBlock.java @@ -0,0 +1,14 @@ +package org.jabref.gui.walkthrough.declarative.richtext; + +import java.util.function.BiFunction; + +import javafx.scene.Node; + +import org.jabref.gui.walkthrough.Walkthrough; + +import org.jspecify.annotations.NonNull; + +public record ArbitraryJFXBlock( + @NonNull BiFunction componentFactory) + implements WalkthroughRichTextBlock { +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/InfoBlock.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/InfoBlock.java new file mode 100644 index 00000000000..36488282843 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/InfoBlock.java @@ -0,0 +1,7 @@ +package org.jabref.gui.walkthrough.declarative.richtext; + +import org.jspecify.annotations.NonNull; + +public record InfoBlock( + @NonNull String text) implements WalkthroughRichTextBlock { +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/TextBlock.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/TextBlock.java new file mode 100644 index 00000000000..6f888888323 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/TextBlock.java @@ -0,0 +1,7 @@ +package org.jabref.gui.walkthrough.declarative.richtext; + +import org.jspecify.annotations.NonNull; + +public record TextBlock( + @NonNull String text) implements WalkthroughRichTextBlock { +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/WalkthroughRichTextBlock.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/WalkthroughRichTextBlock.java new file mode 100644 index 00000000000..04fa0403a07 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/richtext/WalkthroughRichTextBlock.java @@ -0,0 +1,4 @@ +package org.jabref.gui.walkthrough.declarative.richtext; + +public sealed interface WalkthroughRichTextBlock permits InfoBlock, TextBlock, ArbitraryJFXBlock { +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/PanelPosition.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/PanelPosition.java new file mode 100644 index 00000000000..475524a6f64 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/PanelPosition.java @@ -0,0 +1,8 @@ +package org.jabref.gui.walkthrough.declarative.step; + +public enum PanelPosition { + TOP, + BOTTOM, + LEFT, + RIGHT +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/PanelStep.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/PanelStep.java new file mode 100644 index 00000000000..e94853f2e08 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/PanelStep.java @@ -0,0 +1,188 @@ +package org.jabref.gui.walkthrough.declarative.step; + +import java.util.List; +import java.util.Optional; +import java.util.OptionalDouble; + +import org.jabref.gui.walkthrough.declarative.NavigationPredicate; +import org.jabref.gui.walkthrough.declarative.NodeResolver; +import org.jabref.gui.walkthrough.declarative.WindowResolver; +import org.jabref.gui.walkthrough.declarative.effect.HighlightEffect; +import org.jabref.gui.walkthrough.declarative.effect.MultiWindowHighlight; +import org.jabref.gui.walkthrough.declarative.effect.WindowEffect; +import org.jabref.gui.walkthrough.declarative.richtext.WalkthroughRichTextBlock; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +public record PanelStep( + @NonNull String title, + @NonNull List content, + @Nullable NodeResolver resolverValue, + @Nullable String continueButtonTextValue, + @Nullable String skipButtonTextValue, + @Nullable String backButtonTextValue, + @Nullable NavigationPredicate navigationPredicateValue, + @NonNull PanelPosition position, + @Nullable Double widthValue, + @Nullable Double heightValue, + @Nullable MultiWindowHighlight highlightValue, + @Nullable WindowResolver activeWindowResolverValue) implements WalkthroughStep { + + @Override + public Optional resolver() { + return Optional.ofNullable(resolverValue); + } + + @Override + public Optional continueButtonText() { + return Optional.ofNullable(continueButtonTextValue); + } + + @Override + public Optional skipButtonText() { + return Optional.ofNullable(skipButtonTextValue); + } + + @Override + public Optional backButtonText() { + return Optional.ofNullable(backButtonTextValue); + } + + @Override + public Optional navigationPredicate() { + return Optional.ofNullable(navigationPredicateValue); + } + + @Override + public OptionalDouble width() { + return widthValue != null ? OptionalDouble.of(widthValue) : OptionalDouble.empty(); + } + + @Override + public OptionalDouble height() { + return heightValue != null ? OptionalDouble.of(heightValue) : OptionalDouble.empty(); + } + + @Override + public Optional highlight() { + return Optional.ofNullable(highlightValue); + } + + @Override + public Optional activeWindowResolver() { + return Optional.ofNullable(activeWindowResolverValue); + } + + public static Builder builder(@NonNull String title) { + return new Builder(title); + } + + public static class Builder { + private final String title; + private List content = List.of(); + private @Nullable NodeResolver resolver; + private @Nullable String continueButtonText; + private @Nullable String skipButtonText; + private @Nullable String backButtonText; + private @Nullable NavigationPredicate navigationPredicate; + private PanelPosition position = PanelPosition.LEFT; + private @Nullable Double width; + private @Nullable Double height; + private @Nullable MultiWindowHighlight highlight; + private @Nullable WindowResolver activeWindowResolver; + + private Builder(@NonNull String title) { + this.title = title; + } + + public Builder content(@NonNull WalkthroughRichTextBlock... blocks) { + this.content = List.of(blocks); + return this; + } + + public Builder content(@NonNull List content) { + this.content = content; + return this; + } + + public Builder resolver(@NonNull NodeResolver resolver) { + this.resolver = resolver; + return this; + } + + public Builder continueButton(@NonNull String text) { + this.continueButtonText = text; + return this; + } + + public Builder skipButton(@NonNull String text) { + this.skipButtonText = text; + return this; + } + + public Builder backButton(@NonNull String text) { + this.backButtonText = text; + return this; + } + + public Builder navigation(@NonNull NavigationPredicate navigationPredicate) { + this.navigationPredicate = navigationPredicate; + return this; + } + + public Builder position(@NonNull PanelPosition position) { + this.position = position; + return this; + } + + public Builder width(double width) { + this.width = width; + return this; + } + + public Builder height(double height) { + this.height = height; + return this; + } + + public Builder highlight(@NonNull MultiWindowHighlight highlight) { + this.highlight = highlight; + return this; + } + + public Builder highlight(@NonNull WindowEffect effect) { + return highlight(new MultiWindowHighlight(effect)); + } + + public Builder highlight(@NonNull HighlightEffect effect) { + return highlight(new WindowEffect(effect)); + } + + public Builder activeWindow(@NonNull WindowResolver activeWindowResolver) { + this.activeWindowResolver = activeWindowResolver; + return this; + } + + public PanelStep build() { + if (height != null && (position == PanelPosition.LEFT || position == PanelPosition.RIGHT)) { + throw new IllegalArgumentException("Height is not applicable for left/right positioned panels."); + } + if (width != null && (position == PanelPosition.TOP || position == PanelPosition.BOTTOM)) { + throw new IllegalArgumentException("Width is not applicable for top/bottom positioned panels."); + } + return new PanelStep(title, + content, + resolver, + continueButtonText, + skipButtonText, + backButtonText, + navigationPredicate, + position, + width, + height, + highlight, + activeWindowResolver); + } + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/TooltipPosition.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/TooltipPosition.java new file mode 100644 index 00000000000..eb59728cefd --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/TooltipPosition.java @@ -0,0 +1,9 @@ +package org.jabref.gui.walkthrough.declarative.step; + +public enum TooltipPosition { + AUTO, + TOP, + BOTTOM, + LEFT, + RIGHT +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/TooltipStep.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/TooltipStep.java new file mode 100644 index 00000000000..3b29f32649d --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/TooltipStep.java @@ -0,0 +1,186 @@ +package org.jabref.gui.walkthrough.declarative.step; + +import java.util.List; +import java.util.Optional; +import java.util.OptionalDouble; + +import org.jabref.gui.walkthrough.declarative.NavigationPredicate; +import org.jabref.gui.walkthrough.declarative.NodeResolver; +import org.jabref.gui.walkthrough.declarative.WindowResolver; +import org.jabref.gui.walkthrough.declarative.effect.HighlightEffect; +import org.jabref.gui.walkthrough.declarative.effect.MultiWindowHighlight; +import org.jabref.gui.walkthrough.declarative.effect.WindowEffect; +import org.jabref.gui.walkthrough.declarative.richtext.WalkthroughRichTextBlock; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +public record TooltipStep( + @NonNull String title, + @NonNull List content, + @NonNull NodeResolver resolverValue, + @Nullable String continueButtonTextValue, + @Nullable String skipButtonTextValue, + @Nullable String backButtonTextValue, + @Nullable NavigationPredicate navigationPredicateValue, + @NonNull TooltipPosition position, + @Nullable Double widthValue, + @Nullable Double heightValue, + @Nullable MultiWindowHighlight highlightValue, + @Nullable WindowResolver activeWindowResolverValue +) implements WalkthroughStep { + + @Override + public Optional resolver() { + return Optional.of(resolverValue); + } + + @Override + public Optional continueButtonText() { + return Optional.ofNullable(continueButtonTextValue); + } + + @Override + public Optional skipButtonText() { + return Optional.ofNullable(skipButtonTextValue); + } + + @Override + public Optional backButtonText() { + return Optional.ofNullable(backButtonTextValue); + } + + @Override + public Optional navigationPredicate() { + return Optional.ofNullable(navigationPredicateValue); + } + + @Override + public OptionalDouble width() { + return widthValue != null ? OptionalDouble.of(widthValue) : OptionalDouble.empty(); + } + + @Override + public OptionalDouble height() { + return heightValue != null ? OptionalDouble.of(heightValue) : OptionalDouble.empty(); + } + + @Override + public Optional highlight() { + return Optional.ofNullable(highlightValue); + } + + @Override + public Optional activeWindowResolver() { + return Optional.ofNullable(activeWindowResolverValue); + } + + public static Builder builder(@NonNull String title) { + return new Builder(title); + } + + public static class Builder { + private final String title; + private List content = List.of(); + private @Nullable NodeResolver resolver; + private @Nullable String continueButtonText; + private @Nullable String skipButtonText; + private @Nullable String backButtonText; + private @Nullable NavigationPredicate navigationPredicate; + private TooltipPosition position = TooltipPosition.AUTO; + private @Nullable Double width; + private @Nullable Double height; + private @Nullable MultiWindowHighlight highlight; + private @Nullable WindowResolver activeWindowResolver; + + private Builder(@NonNull String title) { + this.title = title; + } + + public Builder content(@NonNull WalkthroughRichTextBlock... blocks) { + this.content = List.of(blocks); + return this; + } + + public Builder content(@NonNull List content) { + this.content = content; + return this; + } + + public Builder resolver(@NonNull NodeResolver resolver) { + this.resolver = resolver; + return this; + } + + public Builder continueButton(@NonNull String text) { + this.continueButtonText = text; + return this; + } + + public Builder skipButton(@NonNull String text) { + this.skipButtonText = text; + return this; + } + + public Builder backButton(@NonNull String text) { + this.backButtonText = text; + return this; + } + + public Builder navigation(@NonNull NavigationPredicate navigationPredicate) { + this.navigationPredicate = navigationPredicate; + return this; + } + + public Builder position(@NonNull TooltipPosition position) { + this.position = position; + return this; + } + + public Builder width(double width) { + this.width = width; + return this; + } + + public Builder height(double height) { + this.height = height; + return this; + } + + public Builder highlight(@NonNull MultiWindowHighlight highlight) { + this.highlight = highlight; + return this; + } + + public Builder highlight(@NonNull WindowEffect effect) { + return highlight(new MultiWindowHighlight(effect)); + } + + public Builder highlight(@NonNull HighlightEffect effect) { + return highlight(new WindowEffect(effect)); + } + + public Builder activeWindow(@NonNull WindowResolver activeWindowResolver) { + this.activeWindowResolver = activeWindowResolver; + return this; + } + + public TooltipStep build() { + if (resolver == null) { + throw new IllegalStateException("Node resolver is required for TooltipStep"); + } + return new TooltipStep(title, + content, + resolver, + continueButtonText, + skipButtonText, + backButtonText, + navigationPredicate, + position, + width, + height, + highlight, + activeWindowResolver); + } + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/WalkthroughStep.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/WalkthroughStep.java new file mode 100644 index 00000000000..79ba5343f98 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/declarative/step/WalkthroughStep.java @@ -0,0 +1,43 @@ +package org.jabref.gui.walkthrough.declarative.step; + +import java.util.List; +import java.util.Optional; +import java.util.OptionalDouble; + +import org.jabref.gui.walkthrough.declarative.NavigationPredicate; +import org.jabref.gui.walkthrough.declarative.NodeResolver; +import org.jabref.gui.walkthrough.declarative.WindowResolver; +import org.jabref.gui.walkthrough.declarative.effect.MultiWindowHighlight; +import org.jabref.gui.walkthrough.declarative.richtext.WalkthroughRichTextBlock; + +public sealed interface WalkthroughStep permits PanelStep, TooltipStep { + String title(); + + List content(); + + Optional resolver(); + + Optional continueButtonText(); + + Optional skipButtonText(); + + Optional backButtonText(); + + Optional navigationPredicate(); + + OptionalDouble width(); + + OptionalDouble height(); + + Optional highlight(); + + Optional activeWindowResolver(); + + static TooltipStep.Builder tooltip(String key) { + return TooltipStep.builder(key); + } + + static PanelStep.Builder panel(String title) { + return PanelStep.builder(title); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/BackdropHighlight.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/BackdropHighlight.java new file mode 100644 index 00000000000..fb58f66da75 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/BackdropHighlight.java @@ -0,0 +1,101 @@ +package org.jabref.gui.walkthrough.effects; + +import javafx.geometry.Bounds; +import javafx.scene.Node; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Rectangle; +import javafx.scene.shape.Shape; + +import org.jabref.gui.walkthrough.WalkthroughUpdater; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +public class BackdropHighlight extends WalkthroughEffect { + private static final Color OVERLAY_COLOR = Color.rgb(0, 0, 0, 0.55); + + private @Nullable Node node; + private Rectangle backdrop; + private Rectangle hole; + private @Nullable Shape overlayShape; + + public BackdropHighlight(@NonNull Pane pane) { + super(pane); + } + + public void attach(@NonNull Node node) { + detach(); + if (overlayShape == null) { + initializeEffect(); + } + this.node = node; + setupNodeListeners(this.node); + setupPaneListeners(); + updateLayout(); + } + + @Override + public void detach() { + super.detach(); + if (overlayShape != null && overlayShape.getParent() instanceof Pane parentPane) { + parentPane.getChildren().remove(overlayShape); + overlayShape = null; + } + this.node = null; + } + + @Override + protected void initializeEffect() { + this.backdrop = new Rectangle(); + this.hole = new Rectangle(); + this.overlayShape = Shape.subtract(backdrop, hole); + this.overlayShape.setFill(OVERLAY_COLOR); + this.overlayShape.setVisible(false); + this.pane.getChildren().add(overlayShape); + } + + @Override + protected void updateLayout() { + if (WalkthroughUpdater.cannotPositionNode(node)) { + hideEffect(); + return; + } + + Bounds bounds = node.localToScene(node.getBoundsInLocal()); + + if (bounds == null || bounds.getWidth() <= 0 || bounds.getHeight() <= 0) { + hideEffect(); + return; + } + + backdrop.setX(0); + backdrop.setY(0); + backdrop.setWidth(pane.getWidth()); + backdrop.setHeight(pane.getHeight()); + + Bounds nodeBoundsInRootPane = pane.sceneToLocal(bounds); + hole.setX(nodeBoundsInRootPane.getMinX()); + hole.setY(nodeBoundsInRootPane.getMinY()); + hole.setWidth(nodeBoundsInRootPane.getWidth()); + hole.setHeight(nodeBoundsInRootPane.getHeight()); + + Shape oldOverlayShape = this.overlayShape; + int oldIndex = -1; + if (this.pane.getChildren().contains(oldOverlayShape)) { + oldIndex = this.pane.getChildren().indexOf(oldOverlayShape); + this.pane.getChildren().remove(oldIndex); + } + + this.overlayShape = Shape.subtract(backdrop, hole); + this.overlayShape.setFill(OVERLAY_COLOR); + this.overlayShape.setVisible(true); + this.pane.getChildren().add(oldIndex, this.overlayShape); + } + + @Override + protected void hideEffect() { + assert overlayShape != null : "Overlay shape should be initialized before hiding effect"; + overlayShape.setVisible(false); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/FullScreenDarken.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/FullScreenDarken.java new file mode 100644 index 00000000000..91a11091fad --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/FullScreenDarken.java @@ -0,0 +1,65 @@ +package org.jabref.gui.walkthrough.effects; + +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Rectangle; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Creates a full screen darken effect. Usually used to force user to ignore certain + * window and focus on the modal. + */ +public class FullScreenDarken extends WalkthroughEffect { + private static final Color OVERLAY_COLOR = Color.rgb(0, 0, 0, 0.55); + + private @Nullable Rectangle overlay; + + public FullScreenDarken(@NonNull Pane pane) { + super(pane); + } + + @Override + protected void initializeEffect() { + this.overlay = new Rectangle(); + this.overlay.setFill(OVERLAY_COLOR); + this.overlay.setVisible(false); + this.overlay.setManaged(false); + this.pane.getChildren().add(overlay); + } + + public void attach() { + updater.cleanup(); + if (overlay == null) { + initializeEffect(); + } + setupPaneListeners(); + updateLayout(); + } + + @Override + public void detach() { + super.detach(); + assert overlay != null : "Run attach() before detach()"; + overlay.setVisible(false); + pane.getChildren().remove(overlay); + overlay = null; + } + + @Override + protected void updateLayout() { + assert overlay != null : "Run attach() before updateLayout()"; + overlay.setX(0); + overlay.setY(0); + overlay.setWidth(pane.getWidth()); + overlay.setHeight(pane.getHeight()); + overlay.setVisible(true); + } + + @Override + protected void hideEffect() { + assert overlay != null : "Run attach() before hideEffect()"; + overlay.setVisible(false); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/PulseAnimateIndicator.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/PulseAnimateIndicator.java new file mode 100644 index 00000000000..bad3b311f75 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/PulseAnimateIndicator.java @@ -0,0 +1,102 @@ +package org.jabref.gui.walkthrough.effects; + +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.geometry.Bounds; +import javafx.scene.Node; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.shape.Circle; +import javafx.util.Duration; + +import org.jabref.gui.walkthrough.WalkthroughUpdater; + +import org.jspecify.annotations.NonNull; + +public class PulseAnimateIndicator extends WalkthroughEffect { + public static final int INDICATOR_OFFSET = 5; + private Circle pulseIndicator; + private Timeline pulseAnimation; + private Node node; + + public PulseAnimateIndicator(@NonNull Pane pane) { + super(pane); + } + + public void attach(@NonNull Node node) { + updater.cleanup(); + if (pulseIndicator == null) { + initializeEffect(); + } + this.node = node; + setupNodeListeners(this.node); + updateLayout(); + } + + @Override + public void detach() { + pulseAnimation.stop(); + if (pulseIndicator.getParent() instanceof Pane parentPane) { + parentPane.getChildren().remove(pulseIndicator); + } + super.detach(); + node = null; + } + + @Override + protected void initializeEffect() { + pulseIndicator = new Circle(8, Color.web("#50618F")); + pulseIndicator.setMouseTransparent(true); + pulseIndicator.setManaged(false); + + pane.getChildren().add(pulseIndicator); + + pulseAnimation = new Timeline( + new KeyFrame(Duration.ZERO, + new KeyValue(pulseIndicator.opacityProperty(), 1.0), + new KeyValue(pulseIndicator.scaleXProperty(), 1.0), + new KeyValue(pulseIndicator.scaleYProperty(), 1.0)), + new KeyFrame(Duration.seconds(0.5), + new KeyValue(pulseIndicator.opacityProperty(), 0.6), + new KeyValue(pulseIndicator.scaleXProperty(), 1.3), + new KeyValue(pulseIndicator.scaleYProperty(), 1.3)), + new KeyFrame(Duration.seconds(1.0), + new KeyValue(pulseIndicator.opacityProperty(), 1.0), + new KeyValue(pulseIndicator.scaleXProperty(), 1.0), + new KeyValue(pulseIndicator.scaleYProperty(), 1.0)) + ); + + pulseAnimation.setCycleCount(Timeline.INDEFINITE); + pulseAnimation.play(); + } + + @Override + protected void updateLayout() { + if (WalkthroughUpdater.cannotPositionNode(node)) { + hideEffect(); + return; + } + + Bounds localBounds = node.getBoundsInLocal(); + Bounds targetBounds = pane.sceneToLocal(node.localToScene(localBounds)); + if (targetBounds == null) { + hideEffect(); + return; + } + + pulseIndicator.setVisible(true); + + double indicatorX = targetBounds.getMaxX() - INDICATOR_OFFSET; + double indicatorY = targetBounds.getMinY() + INDICATOR_OFFSET; + + pulseIndicator.setLayoutX(indicatorX); + pulseIndicator.setLayoutY(indicatorY); + pulseIndicator.toFront(); + } + + @Override + protected void hideEffect() { + pulseIndicator.setVisible(false); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/WalkthroughEffect.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/WalkthroughEffect.java new file mode 100644 index 00000000000..7bccddf93d1 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/effects/WalkthroughEffect.java @@ -0,0 +1,63 @@ +package org.jabref.gui.walkthrough.effects; + +import javafx.scene.Node; +import javafx.scene.layout.Pane; + +import org.jabref.gui.walkthrough.WalkthroughUpdater; + +import org.jspecify.annotations.NonNull; + +/// Base class for walkthrough effects BackdropHighlight, TooltipHighlight, FullScreenDarken, etc. +public abstract class WalkthroughEffect { + protected final Pane pane; + protected final WalkthroughUpdater updater = new WalkthroughUpdater(); + + /** + * Constructor for WalkthroughEffect. + * + * @param pane The pane where the effect will be applied. Usually obtained from window.getScene().getRoot(). + */ + protected WalkthroughEffect(@NonNull Pane pane) { + this.pane = pane; + initializeEffect(); + } + + protected abstract void initializeEffect(); + + protected abstract void updateLayout(); + + /** + * Hide the effect, e.g., by making it invisible. The effect should not be removed from the pane, + * and the scene graph is not modified. + */ + protected abstract void hideEffect(); + + /** + * Detach the effect, cleaning up any resources and listeners. The effect is no longer active + * and reattaching will require scene graph modifications. + */ + public void detach() { + updater.cleanup(); + hideEffect(); + } + + protected void setupNodeListeners(@NonNull Node node) { + updater.setupNodeListeners(node, this::updateLayout); + } + + protected void setupPaneListeners() { + updater.listen(pane.widthProperty(), _ -> updateLayout()); + updater.listen(pane.heightProperty(), _ -> updateLayout()); + updater.listen(pane.sceneProperty(), (_, _, newScene) -> { + updateLayout(); + if (newScene == null) { + return; + } + updater.listen(newScene.heightProperty(), _ -> updateLayout()); + updater.listen(newScene.widthProperty(), _ -> updateLayout()); + if (newScene.getWindow() != null) { + updater.setupWindowListeners(newScene.getWindow(), this::updateLayout); + } + }); + } +} diff --git a/jabgui/src/main/resources/org/jabref/gui/Base.css b/jabgui/src/main/resources/org/jabref/gui/Base.css index 6c0788b4788..48d71077b9c 100644 --- a/jabgui/src/main/resources/org/jabref/gui/Base.css +++ b/jabgui/src/main/resources/org/jabref/gui/Base.css @@ -2519,3 +2519,147 @@ journalInfo .grid-cell-b { -fx-font-size: 1.25em; -fx-border-color: transparent; } + +/* Walkthrough Styles */ +.walkthrough-overlay { + -fx-background-color: transparent; +} + +/* Tooltip Content */ +.walkthrough-tooltip-content-container { + -fx-spacing: 0.75em; + -fx-padding: 1em; +} + +.walkthrough-tooltip-title { + -fx-text-fill: -jr-theme; + -fx-font-size: 1.4em; + -fx-font-weight: bold; +} + +.walkthrough-tooltip-content { + -fx-spacing: 0.5em; +} + +.walkthrough-tooltip-actions { + -fx-spacing: 0; +} + +.walkthrough-panel { + -fx-background-color: -jr-white; + -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 10, 0, 0, 2); + -fx-padding: 1em; + -fx-spacing: 1em; +} + +.walkthrough-title { + -fx-text-fill: -jr-theme; + -fx-font-size: 3em; + -fx-font-weight: bold; +} + +.walkthrough-side-panel-vertical .walkthrough-title, +.walkthrough-side-panel-horizontal .walkthrough-title { + -fx-font-size: 2em; +} + +.walkthrough-side-panel-vertical, +.walkthrough-side-panel-horizontal { + -fx-padding: 2em; +} + +.walkthrough-side-panel-vertical { + -fx-pref-width: 32em; + -fx-max-width: 32em; + -fx-min-width: 32em; +} + +.walkthrough-side-panel-horizontal { + -fx-pref-height: 18em; + -fx-max-height: 18em; + -fx-min-height: 18em; +} + +.walkthrough-content { + -fx-spacing: 0.8em; +} + +.walkthrough-step-counter { + -fx-background-color: -jr-base; + -fx-text-fill: -jr-gray-3; + -fx-font-size: 1em; + -fx-background-radius: 100; + -fx-padding: 0.25em 0.75em; + -fx-alignment: center; +} + +.walkthrough-text-content { + -fx-text-fill: -jr-gray-3; + -fx-font-size: 1.2em; + -fx-wrap-text: true; + -fx-alignment: baseline-left; +} + +.walkthrough-info-container { + -fx-border-color: -jr-theme; + -fx-border-width: 0 0 0 1; + -fx-padding: 0 0 0 0.75em; + -fx-spacing: 0.25em; + -fx-alignment: center-left; +} + +.walkthrough-info-container .ikonli-font-icon { + -fx-icon-color: -jr-theme; + -fx-font-size: 1.2em; +} + +.walkthrough-info-container label { + -fx-text-fill: -jr-theme; + -fx-font-size: 1.2em; + -fx-wrap-text: true; + -fx-alignment: center-left; +} + +.walkthrough-actions { + -fx-spacing: 0; +} + +.walkthrough-right-actions { + -fx-spacing: 4; +} + +.walkthrough-continue-button { + -fx-background-color: -jr-theme; + -fx-text-fill: -jr-white; + -fx-font-size: 1.1em; + -fx-border-radius: 4; + -fx-background-radius: 4; + -fx-padding: 0.25em 0.5em; +} + +.walkthrough-skip-button { + -fx-background-color: transparent; + -fx-text-fill: -jr-theme; + -fx-font-size: 1.1em; + -fx-border-color: -jr-theme; + -fx-border-width: 0.5; + -fx-border-radius: 4; + -fx-background-radius: 4; + -fx-padding: 0.25em 0.5em; +} + +.walkthrough-back-button { + -fx-background-color: transparent; + -fx-text-fill: -jr-theme; + -fx-font-size: 1.1em; + -fx-border-radius: 4; + -fx-background-radius: 4; + -fx-padding: 0.25em 0.5em; +} + +.walkthrough-fullscreen-content { + -fx-padding: 0.1875em; + -fx-spacing: 1em; + -fx-max-width: 48em; + -fx-fill-width: true; +} diff --git a/jablib/src/main/java/org/jabref/logic/preferences/CliPreferences.java b/jablib/src/main/java/org/jabref/logic/preferences/CliPreferences.java index ee84f03f632..412ba59d013 100644 --- a/jablib/src/main/java/org/jabref/logic/preferences/CliPreferences.java +++ b/jablib/src/main/java/org/jabref/logic/preferences/CliPreferences.java @@ -113,5 +113,7 @@ public interface CliPreferences { LastFilesOpenedPreferences getLastFilesOpenedPreferences(); + WalkthroughPreferences getWalkthroughPreferences(); + OpenOfficePreferences getOpenOfficePreferences(JournalAbbreviationRepository journalAbbreviationRepository); } diff --git a/jablib/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java b/jablib/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java index df98ef46882..9e4cb036172 100644 --- a/jablib/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java +++ b/jablib/src/main/java/org/jabref/logic/preferences/JabRefCliPreferences.java @@ -119,14 +119,14 @@ import org.slf4j.LoggerFactory; /** - * The {@code JabRefPreferences} class provides the preferences and their defaults using the JDK {@code java.util.prefs} - * class. + * The {@code JabRefPreferences} class provides the preferences and their defaults using + * the JDK {@code java.util.prefs} class. *

- * Internally it defines symbols used to pick a value from the {@code java.util.prefs} interface and keeps a hashmap - * with all the default values. + * Internally it defines symbols used to pick a value from the {@code java.util.prefs} + * interface and keeps a hashmap with all the default values. *

- * There are still some similar preferences classes ({@link OpenOfficePreferences} and {@link SharedDatabasePreferences}) which also use - * the {@code java.util.prefs} API. + * There are still some similar preferences classes ({@link OpenOfficePreferences} and + * {@link SharedDatabasePreferences}) which also use the {@code java.util.prefs} API. *

* contents of the defaults HashMap that are defined in this class. * There are more default parameters in this map which belong to separate preference classes. @@ -272,7 +272,15 @@ public class JabRefCliPreferences implements CliPreferences { public static final String SHOW_SCITE_TAB = "showSciteTab"; /** - * The OpenOffice/LibreOffice connection preferences are: OO_PATH main directory for OO/LO installation, used to detect location on Win/macOS when using manual connect OO_EXECUTABLE_PATH path to soffice-file OO_JARS_PATH directory that contains juh.jar, jurt.jar, ridl.jar, unoil.jar OO_SYNC_WHEN_CITING true if the reference list is updated when adding a new citation OO_SHOW_PANEL true if the OO panel is shown on startup OO_USE_ALL_OPEN_DATABASES true if all databases should be used when citing OO_BIBLIOGRAPHY_STYLE_FILE path to the used style file OO_EXTERNAL_STYLE_FILES list with paths to external style files STYLES_*_* size and position of "Select style" dialog + * The OpenOffice/LibreOffice connection preferences are: OO_PATH main directory for + * OO/LO installation, used to detect location on Win/macOS when using manual + * connect OO_EXECUTABLE_PATH path to soffice-file OO_JARS_PATH directory that + * contains juh.jar, jurt.jar, ridl.jar, unoil.jar OO_SYNC_WHEN_CITING true if the + * reference list is updated when adding a new citation OO_SHOW_PANEL true if the OO + * panel is shown on startup OO_USE_ALL_OPEN_DATABASES true if all databases should + * be used when citing OO_BIBLIOGRAPHY_STYLE_FILE path to the used style file + * OO_EXTERNAL_STYLE_FILES list with paths to external style files STYLES_*_* size + * and position of "Select style" dialog */ public static final String OO_EXECUTABLE_PATH = "ooExecutablePath"; public static final String OO_SYNC_WHEN_CITING = "syncOOWhenCiting"; @@ -396,6 +404,8 @@ public class JabRefCliPreferences implements CliPreferences { private static final String OPEN_FILE_EXPLORER_IN_FILE_DIRECTORY = "openFileExplorerInFileDirectory"; private static final String OPEN_FILE_EXPLORER_IN_LAST_USED_DIRECTORY = "openFileExplorerInLastUsedDirectory"; + private static final String MAIN_FILE_DIRECTORY_WALKTHROUGH_COMPLETED = "mainFileDirectoryWalkthroughCompleted"; + private static final Logger LOGGER = LoggerFactory.getLogger(JabRefCliPreferences.class); private static final Preferences PREFS_NODE = Preferences.userRoot().node("/org/jabref"); @@ -439,6 +449,7 @@ public class JabRefCliPreferences implements CliPreferences { private FieldPreferences fieldPreferences; private AiPreferences aiPreferences; private LastFilesOpenedPreferences lastFilesOpenedPreferences; + private WalkthroughPreferences walkthroughPreferences; /** * @implNote The constructor was made public because dependency injection via constructor @@ -709,6 +720,9 @@ public JabRefCliPreferences() { // endregion // endregion + + // WalkThrough + defaults.put(MAIN_FILE_DIRECTORY_WALKTHROUGH_COMPLETED, Boolean.FALSE); } public void setLanguageDependentDefaultValues() { @@ -723,9 +737,10 @@ public void setLanguageDependentDefaultValues() { } /** - * @deprecated Never ever add a call to this method. There should be only one caller. - * All other usages should get the preferences passed (or injected). - * The JabRef team leaves the {@code @deprecated} annotation to have IntelliJ listing this method with a strike-through. + * @deprecated Never ever add a call to this method. There should be only one + * caller. All other usages should get the preferences passed (or injected). The + * JabRef team leaves the {@code @deprecated} annotation to have IntelliJ listing + * this method with a strike-through. */ @Deprecated public static JabRefCliPreferences getInstance() { @@ -841,7 +856,9 @@ protected void remove(String key) { } /** - * Puts a list of strings into the Preferences, by linking its elements with a STRINGLIST_DELIMITER into a single string. Escape characters make the process transparent even if strings contains a STRINGLIST_DELIMITER. + * Puts a list of strings into the Preferences, by linking its elements with a + * STRINGLIST_DELIMITER into a single string. Escape characters make the process + * transparent even if strings contains a STRINGLIST_DELIMITER. */ public void putStringList(String key, List value) { if (value == null) { @@ -870,7 +887,8 @@ private Path getPath(String key, Path defaultValue) { /** * Clear all preferences. * - * @throws BackingStoreException if JabRef is unable to write to the registry/the preferences storage + * @throws BackingStoreException if JabRef is unable to write to the registry/the + * preferences storage */ @Override public void clear() throws BackingStoreException { @@ -978,7 +996,8 @@ protected List getSeries(String key) { } /** - * Removes all entries keyed by prefix+number, where number is equal to or higher than the given number. + * Removes all entries keyed by prefix+number, where number is equal to or higher + * than the given number. * * @param number or higher. */ @@ -1013,7 +1032,8 @@ public void exportPreferences(Path path) throws JabRefException { * Imports Preferences from an XML file. * * @param file Path of file to import from - * @throws JabRefException thrown if importing the preferences failed due to an InvalidPreferencesFormatException or an IOException + * @throws JabRefException thrown if importing the preferences failed due to an + * InvalidPreferencesFormatException or an IOException */ @Override public void importPreferences(Path file) throws JabRefException { @@ -1688,10 +1708,8 @@ public SelfContainedSaveConfiguration getSelfContainedExportConfiguration() { LOGGER.warn("Table sort order requested, but JabRef is in CLI mode. Falling back to defeault save order"); yield SaveOrder.getDefaultSaveOrder(); } - case SPECIFIED -> - SelfContainedSaveOrder.of(exportSaveOrder); - case ORIGINAL -> - SaveOrder.getDefaultSaveOrder(); + case SPECIFIED -> SelfContainedSaveOrder.of(exportSaveOrder); + case ORIGINAL -> SaveOrder.getDefaultSaveOrder(); }; return new SelfContainedSaveConfiguration( @@ -2303,4 +2321,15 @@ public OpenOfficePreferences getOpenOfficePreferences(JournalAbbreviationReposit return openOfficePreferences; } + + @Override + public WalkthroughPreferences getWalkthroughPreferences() { + if (walkthroughPreferences != null) { + return walkthroughPreferences; + } + + walkthroughPreferences = new WalkthroughPreferences(getBoolean(MAIN_FILE_DIRECTORY_WALKTHROUGH_COMPLETED)); + EasyBind.listen(walkthroughPreferences.mainFileDirectoryCompletedProperty(), (_, _, newValue) -> putBoolean(MAIN_FILE_DIRECTORY_WALKTHROUGH_COMPLETED, newValue)); + return walkthroughPreferences; + } } diff --git a/jablib/src/main/java/org/jabref/logic/preferences/WalkthroughPreferences.java b/jablib/src/main/java/org/jabref/logic/preferences/WalkthroughPreferences.java new file mode 100644 index 00000000000..57eba1c4c57 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/preferences/WalkthroughPreferences.java @@ -0,0 +1,27 @@ +package org.jabref.logic.preferences; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; + +/** + * Preferences related to the application walkthrough functionality. + */ +public class WalkthroughPreferences { + private final BooleanProperty mainFileDirectoryCompleted; + + public WalkthroughPreferences(boolean completed) { + this.mainFileDirectoryCompleted = new SimpleBooleanProperty(completed); + } + + public BooleanProperty mainFileDirectoryCompletedProperty() { + return mainFileDirectoryCompleted; + } + + public boolean getMainFileDirectoryCompleted() { + return mainFileDirectoryCompleted.get(); + } + + public void setMainFileDirectoryCompleted(boolean mainFileDirectoryCompleted) { + this.mainFileDirectoryCompleted.set(mainFileDirectoryCompleted); + } +} diff --git a/jablib/src/main/resources/l10n/JabRef_en.properties b/jablib/src/main/resources/l10n/JabRef_en.properties index c8941c46dde..347c730ba91 100644 --- a/jablib/src/main/resources/l10n/JabRef_en.properties +++ b/jablib/src/main/resources/l10n/JabRef_en.properties @@ -2947,6 +2947,20 @@ You\ must\ specify\ a\ Bib(La)TeX\ source.=You must specify a Bib(La)TeX source. You\ must\ specify\ an\ identifier.=You must specify an identifier. You\ must\ specify\ one\ (or\ more)\ citations.=You must specify one (or more) citations. +# Walkthrough +Configure\ main\ file\ directory=Configure main file directory +Walkthroughs=Walkthroughs +# Configure main file directory walkthrough +Click\ on\ "File"\ menu=Click on "File" menu +Click\ on\ "Preferences"=Click on "Preferences" +Select\ the\ "Linked\ files"\ tab=Select the "Linked files" tab +This\ section\ manages\ how\ JabRef\ handles\ your\ PDF\ files\ and\ other\ documents.=This section manages how JabRef handles your PDF files and other documents. +Enable\ "Main\ file\ directory"\ option=Enable "Main file directory" option +Choose\ this\ option\ to\ tell\ JabRef\ where\ your\ research\ files\ are\ stored.\ This\ makes\ it\ easy\ to\ attach\ PDFs\ and\ other\ documents\ to\ your\ bibliography\ entries.\ You\ can\ browse\ to\ select\ your\ preferred\ folder\ in\ the\ next\ step.=Choose this option to tell JabRef where your research files are stored. This makes it easy to attach PDFs and other documents to your bibliography entries. You can browse to select your preferred folder in the next step. +Click\ "Save"\ to\ save\ changes=Click "Save" to save changes +Congratulations.\ Your\ main\ file\ directory\ is\ now\ configured.\ JabRef\ will\ use\ this\ location\ to\ automatically\ find\ and\ organize\ your\ research\ documents.=Congratulations. Your main file directory is now configured. JabRef will use this location to automatically find and organize your research documents. +Additional\ information\ on\ main\ file\ directory\ can\ be\ found\ in\ https\://docs.jabref.org/v5/finding-sorting-and-cleaning-entries/filelinks=Additional information on main file directory can be found in https://docs.jabref.org/v5/finding-sorting-and-cleaning-entries/filelinks + # CommandLine Available\ export\ formats\:=Available export formats: Available\ import\ formats\:=Available import formats: @@ -2971,6 +2985,7 @@ The\ following\ providers\ are\ available\:=The following providers are availabl Unable\ to\ open\ file\ '%0'.=Unable to open file '%0'. Unknown\ export\ format\ '%0'.=Unknown export format '%0'. Updating\ PDF\ metadata.=Updating PDF metadata. + File\ '%0'\ already\ exists.\ Overwriting.=File '%0' already exists. Overwriting. File\ '%0'\ already\ exists.\ Use\ -f\ or\ --force\ to\ overwrite.=File '%0' already exists. Use -f or --force to overwrite. Pseudonymizing\ library\ '%0'...=Pseudonymizing library '%0'...