/* * BSD-style license; for more info see http://pmd.sourceforge.net/license.html */ package net.sourceforge.pmd.util.fxdesigner.util.controls; import static net.sourceforge.pmd.util.fxdesigner.util.ResourceUtil.resolveResource; import java.io.IOException; import java.net.URL; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.function.Function; import java.util.function.Supplier; import org.checkerframework.checker.nullness.qual.Nullable; import org.kordamp.ikonli.javafx.FontIcon; import org.reactfx.collection.LiveList; import org.reactfx.value.Val; import org.reactfx.value.Var; import net.sourceforge.pmd.util.fxdesigner.app.AbstractController; import net.sourceforge.pmd.util.fxdesigner.app.services.CloseableService; import net.sourceforge.pmd.util.fxdesigner.util.DesignerUtil; import net.sourceforge.pmd.util.fxdesigner.util.reactfx.ReactfxUtil; import javafx.application.Platform; import javafx.beans.NamedArg; import javafx.beans.binding.Binding; import javafx.beans.binding.Bindings; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.scene.control.Button; import javafx.scene.control.SingleSelectionModel; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.control.Tooltip; import javafx.scene.layout.AnchorPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; /** * A tab pane that can add new tabs with a button. * * @param <T> Type of controllers for the content of each tab. Conformance of * the controllers to this type must be enforced by the client. * * @author Clément Fournier */ public final class MutableTabPane<T extends AbstractController & TitleOwner> extends AnchorPane { /** The TabPane hosting the tabs. */ private final TabPane tabPane = new TabPane(); /** Name of the FXML file that will populate the tabs' contents. */ private final String tabFxmlResource; /** Supplier of controllers for each tab. */ private final Var<Supplier<T>> controllerSupplier = Var.newSimpleVar(() -> null); private final Var<@Nullable Function<? super T, ? extends T>> deepCopyFun = Var.newSimpleVar(null); public MutableTabPane(@NamedArg("tabFxmlContent") String tabFxmlContent) { this.tabFxmlResource = Objects.requireNonNull(tabFxmlContent); assert DesignerUtil.getFxml(tabFxmlContent) != null; AnchorPane.setRightAnchor(tabPane, 0d); AnchorPane.setLeftAnchor(tabPane, 0d); AnchorPane.setBottomAnchor(tabPane, 0d); AnchorPane.setTopAnchor(tabPane, 0d); tabPane.getStyleClass().addAll("mutable-tab-pane"); getTabs().addListener((ListChangeListener<Tab>) change -> { final ObservableList<Tab> tabs = getTabs(); tabs.get(0).setClosable(tabs.size() > 1); }); getChildren().addAll(tabPane); initAddButton(); } private void initAddButton() { Val.wrap(tabPane.skinProperty()) .values() .filter(Objects::nonNull) .subscribeForOne(skin -> Platform.runLater(this::makeAddButton)); } private void makeAddButton() { // Basically we superimpose a transparent HBox over the TabPane // it needs to be done in a runLater because we need to access the // TabPane's header region, which is created by the TabPane's skin // on the first layout Region headersRegion = (Region) tabPane.lookup(".headers-region"); // a pane that always has the size of the header region, // pushing the new tab button to its right Pane headerSizePane = new Pane(); headerSizePane.setMouseTransparent(true); headerSizePane.prefWidthProperty().bind(headersRegion.widthProperty()); // the new tab button Button newTabButton = new Button(); newTabButton.getStyleClass().addAll("icon-button", "add-tab-button"); newTabButton.setTooltip(new Tooltip("Add new tab")); newTabButton.setGraphic(new FontIcon("fas-plus")); newTabButton.onActionProperty().set(actionEvent -> addTabWithNewController()); // bind bounds to a square that fits inside the header's region newTabButton.maxHeightProperty().bind(headersRegion.heightProperty()); newTabButton.maxWidthProperty().bind(headersRegion.heightProperty()); // the copy tab button Button copyButton = new Button(); copyButton.getStyleClass().addAll("icon-button", "duplicate-tab-button"); copyButton.setTooltip(new Tooltip("Duplicate current tab")); copyButton.setGraphic(new FontIcon("far-copy")); copyButton.onActionProperty().set(actionEvent -> { T cur = currentFocusedController().getValue(); Function<? super T, ? extends T> copy = deepCopyFun.getValue(); if (cur == null || copy == null) { addTabWithNewController(); } else { addTabWithController(copy.apply(cur)); } }); // bind bounds to a square that fits inside the header's region copyButton.maxHeightProperty().bind(headersRegion.heightProperty()); copyButton.maxWidthProperty().bind(headersRegion.heightProperty()); copyButton.visibleProperty().bind(ReactfxUtil.isPresentProperty(deepCopyFun)); copyButton.managedProperty().bind(copyButton.visibleProperty()); // Rightmost node, grows to fill the rest of the horizontal space Pane spring = new Pane(); spring.setMouseTransparent(true); HBox.setHgrow(spring, Priority.ALWAYS); HBox box = new HBox(); box.getStylesheets().addAll(resolveResource("css/flat.css"), resolveResource("css/designer.css")); // makes the HBox's transparent regions click-through // https://stackoverflow.com/questions/16876083/javafx-pass-mouseevents-through-transparent-node-to-children box.setPickOnBounds(false); box.prefHeightProperty().bind(headersRegion.heightProperty()); box.getChildren().addAll(headerSizePane, newTabButton, copyButton, spring); // Fits the HBox's size to the container AnchorPane.setTopAnchor(box, 0d); AnchorPane.setRightAnchor(box, 0d); AnchorPane.setLeftAnchor(box, 0d); // don't forget that this.getChildren().addAll(box); } /** * Unmodifiable list of controllers for each tab in order. There's at least one. */ public LiveList<T> getControllers() { return LiveList.map(getTabs(), this::controllerFromTab); } /** * Currently focused tab. */ public Val<T> currentFocusedController() { return Val.map(getSelectionModel().selectedItemProperty(), this::controllerFromTab); } /** * Creates a new tab and assigns it a new controller using * the controller supplier ({@link #setControllerSupplier(Supplier)}). * * @return The controller of the tab */ @SuppressWarnings("UnusedReturnValue") public T addTabWithNewController() { Tab tab = newTabWithDefaultController(); addTabAndFocus(tab); return controllerFromTab(tab); } public void addTabWithController(T controller) { Tab tab = tabMaker().apply(Objects.requireNonNull(controller)); Objects.requireNonNull(tab, "Null tab?"); addTabAndFocus(tab); } private void addTabAndFocus(Tab tab) { tab.textProperty().bind(uniqueNameBinding(controllerFromTab(tab).titleProperty(), getTabs().size())); this.getTabs().add(tab); getSelectionModel().select(tab); // Finish the initialisation of the tab controllerFromTab(tab).afterParentInit(); } public void setControllerSupplier(Supplier<T> supplier) { this.controllerSupplier.setValue(supplier); } public void setDeepCopyFunction(Function<? super T, ? extends T> deepCopyFun) { this.deepCopyFun.setValue(deepCopyFun); } /** Retrieves the controller of a tab. */ @SuppressWarnings("unchecked") private T controllerFromTab(Tab tab) { return (T) tab.getUserData(); } private Tab newTabWithDefaultController() { return tabMaker().apply(controllerSupplier.getOrElse(() -> null).get()); } /** Makes the title unique w.r.t. already present tabs. */ private Val<String> uniqueNameBinding(Val<String> titleProperty, int tabIdx) { Binding<String> uniqueBinding = Bindings.createStringBinding( () -> { String title = titleProperty.getOrElse("Unnamed"); int sameName = 0; LiveList<T> controllers = getControllers(); for (int i = 0; i < controllers.size() && i < tabIdx; i++) { if (title.equals(controllers.get(i).titleProperty().getOrElse("Unnamed"))) { sameName++; } } return sameName == 0 ? title : title + " (" + sameName + ")"; }, titleProperty, getTabs() ); return Val.wrap(uniqueBinding); } private ObservableList<Tab> getTabs() { return tabPane.getTabs(); } public SingleSelectionModel<Tab> getSelectionModel() { return tabPane.getSelectionModel(); } /** * Returns an object creating tabs on demand, populated with the content * from the given FXML resource location. Each tab's userdata is its controller, * necessarily of type T. */ private Function<T, Tab> tabMaker() { return controller -> { URL url = DesignerUtil.getFxml(tabFxmlResource); if (url == null) { System.err.println("Unresolved FXML resource " + tabFxmlResource); return null; } FXMLLoader loader = new FXMLLoader(url); if (controller != null) { List<AbstractController> lst = new ArrayList<>(controller.getChildren()); lst.add(0, controller); // TODO this adds the children but not descendants loader.setControllerFactory(DesignerUtil.controllerFactoryKnowing(lst.toArray())); } Parent root; try { root = loader.load(); } catch (IOException e) { System.err.println("Error loading FXML " + tabFxmlResource); e.printStackTrace(); return null; } Tab newTab = new Tab(); newTab.setContent(root); T realController = loader.getController(); newTab.setUserData(realController); newTab.setOnClosed(evt -> { if (realController instanceof CloseableService) { try { ((CloseableService) realController).close(); } catch (Exception e) { e.printStackTrace(); } } }); return newTab; }; } }