com.dua3.utility.fx.controls.WizardDialog Maven / Gradle / Ivy
package com.dua3.utility.fx.controls;
import org.jspecify.annotations.Nullable;
import com.dua3.utility.fx.controls.AbstractDialogPaneBuilder.ResultHandler;
import com.dua3.utility.data.Pair;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanExpression;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
/**
* Represents a wizard dialog that guides the user through a sequence of pages.
* Each page can represent a step in a process, and the wizard dialog allows
* navigation between these steps.
*/
public class WizardDialog extends Dialog<@Nullable Map> {
/**
* Logger instance
*/
private static final Logger LOG = LogManager.getLogger(WizardDialog.class);
/**
* Stack of displayed pages (for navigating back).
*/
private final ObservableList>> pageStack = FXCollections.observableArrayList();
/**
* Cancelable flag.
*/
private boolean cancelable = true;
/**
* Flag: show 'previous'-button?
*/
private boolean showPreviousButton = true;
/**
* Map {@code |-> }.
*/
private @Nullable Map> pages;
/**
* The currently displayed page.
*/
private @Nullable Pair> current;
/**
* WizardDialog initializes a new dialog that handles the navigation and data collection
* of a sequence of wizard pages.
*/
public WizardDialog() {
setResultConverter(btn -> {
if (btn != ButtonType.FINISH) {
return null;
}
// add current page to the stack, then build and return the result map
pageStack.add(Objects.requireNonNull(current, "no pages"));
// WARNING: do not use collect(Collectors.toMap(...)) because it cannot handle null
LinkedHashMap result = new LinkedHashMap<>();
pageStack.forEach(p -> {
assert p.second().result != null;
result.put(p.first(), p.second().result);
});
return result;
});
}
/**
* Sets the wizard pages and the initial page to start from.
*
* @param pages a map where keys are page identifiers and values are the corresponding Page objects
* @param startPage the identifier of the page where the wizard dialog should start
*/
public void setPages(Map> pages, String startPage) {
this.pages = pages;
checkPages();
setPage(startPage);
}
/**
* Verifies the configuration of each page within the wizard, ensuring that next page references exist,
* and sets up the dialog pane buttons for each page.
*
* This method performs the following tasks for each page in the wizard:
*
* - Confirms that the 'next' page reference for each page exists in the set of pages. If a 'next' page is
* referenced that does not exist, an IllegalStateException is thrown.
*
- Prepares the buttons for the page's dialog pane by initializing the buttons through `initButtons()` method.
*
- Adds a 'cancel' button to the dialog pane if the wizard is cancelable.
*
- Adds a 'next' button or 'finish' button to the dialog pane depending on whether the current page has a
* 'next' reference. The 'next' button pushes the current page onto the stack and navigates to the 'next' page.
* The 'finish' button is added if there is no 'next' page.
*
- Adds a 'previous' button to navigate to the previous page if the previous button functionality is enabled.
*
* @throws IllegalStateException if any page refers to a 'next' page that does not exist
*/
private void checkPages() {
if (pages == null) {
return;
}
Set pageNames = pages.keySet();
for (Entry> entry : pages.entrySet()) {
String name = entry.getKey();
Page, ?> page = entry.getValue();
InputDialogPane> pane = page.getPane();
// check page names
String next = page.getNext();
if (next != null && !pageNames.contains(next)) {
throw new IllegalStateException(String.format("Page '%s': next page doesn't exist ['%s']", name, next));
}
// prepare buttons
pane.initButtons();
// cancel button
if (isCancelable()) {
addButtonToDialogPane(page, ButtonType.CANCEL, p -> {}, null);
}
// next button
if (page.getNext() == null) {
addButtonToDialogPane(page, ButtonType.FINISH, p -> {}, pane.validProperty());
} else {
addButtonToDialogPane(
page,
ButtonType.NEXT,
p -> {
pageStack.add(Pair.of(name, page));
setPage(page.getNext());
},
pane.validProperty());
}
// prev button
if (isShowPreviousButton()) {
addButtonToDialogPane(
page,
ButtonType.PREVIOUS,
p -> setPage(pageStack.remove(pageStack.size() - 1).first()),
Bindings.isNotEmpty(pageStack)
);
}
}
}
private void setPage(String pageName) {
this.current = Pair.of(pageName, Objects.requireNonNull(pages, "pages not set").get(pageName));
InputDialogPane> pane = current.second().pane;
setDialogPane(pane);
pane.init();
pane.layout();
pane.getScene().getWindow().sizeToScene();
LOG.debug("current page: {}", pageName);
}
/**
* Check if dialog can be canceled.
*
* @return true if dialog is cancelable
*/
public boolean isCancelable() {
return cancelable;
}
private static void addButtonToDialogPane(
Page, ?> page,
ButtonType bt,
Consumer super InputDialogPane>> action,
@Nullable BooleanExpression enabled) {
InputDialogPane> pane = page.pane;
List buttons = pane.getButtonTypes();
buttons.add(bt);
Button btn = (Button) pane.lookupButton(bt);
// it seems counter-intuitive to use an event filter instead of a handler, but
// when using an event handler, Dialog.close() is called before our own
// event handler.
btn.addEventFilter(ActionEvent.ACTION, evt -> {
// get and translate result
if (!page.apply(bt)) {
LOG.debug("Button {}: result conversion failed", bt);
evt.consume();
}
action.accept(page.getPane());
});
if (enabled != null) {
btn.disableProperty().bind(Bindings.not(enabled));
}
}
/**
* Check if a 'previous' ( or 'navigate-back') button should be displayed.
*
* @return true if dialog is cancelable
*/
public boolean isShowPreviousButton() {
return showPreviousButton;
}
/**
* Retrieves the current wizard page.
*
* @return the current wizard page as a {@link Page} object
*/
public Optional> getCurrentPage() {
return Optional.ofNullable(current).map(Pair::second);
}
/**
* Wizard page information class.
*/
public static class Page, R> {
private final D pane;
private final ResultHandler super R> resultHandler;
private @Nullable String next;
private @Nullable R result;
Page(D pane, ResultHandler super R> resultHandler) {
this.pane = pane;
this.resultHandler = resultHandler;
}
@Nullable String getNext() {
return next;
}
void setNext(@Nullable String next) {
this.next = next;
}
D getPane() {
return pane;
}
boolean apply(ButtonType btn) {
R r = pane.get();
boolean done = resultHandler.handleResult(btn, r);
this.result = done ? r : null;
return done;
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy