diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java index 9b9d6e6948..7b59a4a3b8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java @@ -133,14 +133,19 @@ public CreateAccountPane(AccountFactory factory) { } if (showMethodSwitcher) { - TabControl.Tab[] tabs = new TabControl.Tab[Accounts.FACTORIES.size()]; - TabControl.Tab selected = null; + TabItem[] tabs = new TabItem[Accounts.FACTORIES.size()]; + var tabToFactory = new IdentityHashMap, AccountFactory>(); + + @Nullable TabItem selected = null; for (int i = 0; i < tabs.length; i++) { AccountFactory f = Accounts.FACTORIES.get(i); - tabs[i] = new TabControl.Tab<>(Accounts.getLoginType(f), Accounts.getLocalizedLoginTypeName(f)); - tabs[i].setUserData(f); + TabItem tab = new TabItem<>(); + tabs[i] = tab; + + tab.setText(Accounts.getLocalizedLoginTypeName(f)); + tabToFactory.put(tab, f); if (factory == f) { - selected = tabs[i]; + selected = tab; } } @@ -148,12 +153,13 @@ public CreateAccountPane(AccountFactory factory) { tabHeader.getStyleClass().add("add-account-tab-header"); tabHeader.setMinWidth(USE_PREF_SIZE); tabHeader.setMaxWidth(USE_PREF_SIZE); - tabHeader.getSelectionModel().select(selected); - onChange(tabHeader.getSelectionModel().selectedItemProperty(), + if (selected != null) + tabHeader.getController().selectTab(selected); + onChange(tabHeader.getController().selectedTabProperty(), newItem -> { if (newItem == null) return; - AccountFactory newMethod = (AccountFactory) newItem.getUserData(); + AccountFactory newMethod = tabToFactory.get(newItem); settings().preferredLoginTypeProperty().set(Accounts.getLoginType(newMethod)); this.factory = newMethod; initDetailsPane(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java index 1cbc350382..849e8ab7b9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java @@ -22,7 +22,7 @@ @NotNullByDefault public class TransitionPane extends StackPane { - /// The requested page, or `null` before content is assigned. + /// The requested page, or `null` when content is absent. private @Nullable Node currentNode; /// The pending or running transition, or `null` when content is settled. @@ -44,6 +44,20 @@ public TransitionPane() { return currentNode; } + /// Stops any transition and removes displayed and prewarmed content without animation. + /// Resets [#getCurrentNode()] to null. Repeated calls on an empty pane have no effect. + /// Nodes are detached even if settling the transition throws an exception. + public final void clearContent() { + try { + if (activeTransition != null) + activeTransition.stop(); + } finally { + disposePreloadPane(); + currentNode = null; + getChildren().clear(); + } + } + /// Attaches unowned content for hidden CSS and layout prewarming during scene pulses. /// Replaces any previous candidate without changing the current content. The hidden /// container uses a snapshot of this pane's content-area size and does not affect diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java index 656f4f3ae9..8bec4b6c21 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java @@ -14,6 +14,7 @@ import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.SVGContainer; import org.jackhuang.hmcl.ui.animation.Motion; +import org.jetbrains.annotations.Nullable; import java.util.function.Consumer; @@ -77,18 +78,25 @@ public AdvancedListBox addNavigationDrawerItem(String title, SVG leftGraphic, Ru return add(item); } - public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabControl.Tab tab, String title, SVG leftGraphic) { + /// Adds an item that activates the tab through the controller and tracks its selection state. + /// + /// @param leftGraphic the item icon, or null for no icon + /// @return this list box + public AdvancedListBox addNavigationDrawerTab(TabController controller, TabItem tab, String title, @Nullable SVG leftGraphic) { AdvancedListItem item = createNavigationDrawerItem(title, leftGraphic); - item.activeProperty().bind(tabHeader.getSelectionModel().selectedItemProperty().isEqualTo(tab)); - item.setOnAction(e -> tabHeader.select(tab)); + item.activeProperty().bind(controller.selectedTabProperty().isEqualTo(tab)); + item.setOnAction(e -> controller.selectTab(tab)); return add(item); } - public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabControl.Tab tab, String title, + /// Adds an item that activates the tab through the controller and switches icons with its selection state. + /// + /// @return this list box + public AdvancedListBox addNavigationDrawerTab(TabController controller, TabItem tab, String title, SVG unselectedGraphic, SVG selectedGraphic) { AdvancedListItem item = createNavigationDrawerItem(title, null); - item.activeProperty().bind(tabHeader.getSelectionModel().selectedItemProperty().isEqualTo(tab)); - item.setOnAction(e -> tabHeader.select(tab)); + item.activeProperty().bind(controller.selectedTabProperty().isEqualTo(tab)); + item.setOnAction(e -> controller.selectTab(tab)); var leftGraphic = new SVGContainer(item.isActive() ? selectedGraphic : unselectedGraphic, AdvancedListItem.LEFT_ICON_SIZE); leftGraphic.setMouseTransparent(true); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabControl.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabControl.java deleted file mode 100644 index 3b07edb36e..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabControl.java +++ /dev/null @@ -1,262 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors -// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL -package org.jackhuang.hmcl.ui.construct; - -import javafx.beans.property.*; -import javafx.collections.ListChangeListener; -import javafx.collections.ObservableList; -import javafx.scene.AccessibleAttribute; -import javafx.scene.Node; -import javafx.scene.control.SingleSelectionModel; - -import java.util.function.Supplier; - -public interface TabControl { - ObservableList> getTabs(); - - class TabControlSelectionModel extends SingleSelectionModel> { - private final TabControl tabHeader; - - public TabControlSelectionModel(final TabControl t) { - if (t == null) { - throw new NullPointerException("TabPane can not be null"); - } - this.tabHeader = t; - - // watching for changes to the items list content - final ListChangeListener> itemsContentObserver = c -> { - while (c.next()) { - for (Tab tab : c.getRemoved()) { - if (tab != null && !tabHeader.getTabs().contains(tab)) { - if (tab.isSelected()) { - tab.setSelected(false); - final int tabIndex = c.getFrom(); - - // we always try to select the nearest, non-disabled - // tab from the position of the closed tab. - findNearestAvailableTab(tabIndex, true); - } - } - } - if (c.wasAdded() || c.wasRemoved()) { - // The selected tab index can be out of sync with the list of tab if - // we add or remove tabs before the selected tab. - if (getSelectedIndex() != tabHeader.getTabs().indexOf(getSelectedItem())) { - clearAndSelect(tabHeader.getTabs().indexOf(getSelectedItem())); - } - } - } - if (getSelectedIndex() == -1 && getSelectedItem() == null && tabHeader.getTabs().size() > 0) { - // we go looking for the first non-disabled tab, as opposed to - // just selecting the first tab (fix for RT-36908) - findNearestAvailableTab(0, true); - } else if (tabHeader.getTabs().isEmpty()) { - clearSelection(); - } - }; - if (this.tabHeader.getTabs() != null) { - this.tabHeader.getTabs().addListener(itemsContentObserver); - } - } - - // API Implementation - @Override public void select(int index) { - if (index < 0 || (getItemCount() > 0 && index >= getItemCount()) || - (index == getSelectedIndex() && getModelItem(index).isSelected())) { - return; - } - - // Unselect the old tab - if (getSelectedIndex() >= 0 && getSelectedIndex() < tabHeader.getTabs().size()) { - tabHeader.getTabs().get(getSelectedIndex()).setSelected(false); - } - - setSelectedIndex(index); - - Tab tab = getModelItem(index); - if (tab != null) { - setSelectedItem(tab); - } - - // Select the new tab - if (getSelectedIndex() >= 0 && getSelectedIndex() < tabHeader.getTabs().size()) { - tabHeader.getTabs().get(getSelectedIndex()).setSelected(true); - } - - /* Does this get all the change events */ - ((Node) tabHeader).notifyAccessibleAttributeChanged(AccessibleAttribute.FOCUS_ITEM); - } - - @Override public void select(Tab tab) { - final int itemCount = getItemCount(); - - for (int i = 0; i < itemCount; i++) { - final Tab value = getModelItem(i); - if (value != null && value.equals(tab)) { - select(i); - return; - } - } - if (tab != null) { - setSelectedItem(tab); - } - } - - @Override protected Tab getModelItem(int index) { - final ObservableList> items = tabHeader.getTabs(); - if (items == null) return null; - if (index < 0 || index >= items.size()) return null; - return items.get(index); - } - - @Override protected int getItemCount() { - final ObservableList> items = tabHeader.getTabs(); - return items == null ? 0 : items.size(); - } - - private Tab findNearestAvailableTab(int tabIndex, boolean doSelect) { - // we always try to select the nearest, non-disabled - // tab from the position of the closed tab. - final int tabCount = getItemCount(); - int i = 1; - Tab bestTab = null; - while (true) { - // look leftwards - int downPos = tabIndex - i; - if (downPos >= 0) { - Tab _tab = getModelItem(downPos); - if (_tab != null) { - bestTab = _tab; - break; - } - } - - // look rightwards. We subtract one as we need - // to take into account that a tab has been removed - // and if we don't do this we'll miss the tab - // to the right of the tab (as it has moved into - // the removed tabs position). - int upPos = tabIndex + i - 1; - if (upPos < tabCount) { - Tab _tab = getModelItem(upPos); - if (_tab != null) { - bestTab = _tab; - break; - } - } - - if (downPos < 0 && upPos >= tabCount) { - break; - } - i++; - } - - if (doSelect && bestTab != null) { - select(bestTab); - } - - return bestTab; - } - } - - final class Tab { - private final StringProperty id = new SimpleStringProperty(this, "id"); - private final StringProperty text = new SimpleStringProperty(this, "text"); - private final ReadOnlyBooleanWrapper selected = new ReadOnlyBooleanWrapper(this, "selected"); - private final ObjectProperty node = new SimpleObjectProperty<>(this, "node"); - private final ObjectProperty userData = new SimpleObjectProperty<>(this, "userData"); - private Supplier nodeSupplier; - - public Tab(String id) { - setId(id); - } - - public Tab(String id, String text) { - setId(id); - setText(text); - } - - public Supplier getNodeSupplier() { - return nodeSupplier; - } - - public void setNodeSupplier(Supplier nodeSupplier) { - this.nodeSupplier = nodeSupplier; - } - - public String getId() { - return id.get(); - } - - public StringProperty idProperty() { - return id; - } - - public void setId(String id) { - this.id.set(id); - } - - public String getText() { - return text.get(); - } - - public StringProperty textProperty() { - return text; - } - - public void setText(String text) { - this.text.set(text); - } - - public boolean isSelected() { - return selected.get(); - } - - public ReadOnlyBooleanProperty selectedProperty() { - return selected.getReadOnlyProperty(); - } - - private void setSelected(boolean selected) { - this.selected.set(selected); - } - - public T getNode() { - return node.get(); - } - - public ObjectProperty nodeProperty() { - return node; - } - - public void setNode(T node) { - this.node.set(node); - } - - public Object getUserData() { - return userData.get(); - } - - public ObjectProperty userDataProperty() { - return userData; - } - - public void setUserData(Object userData) { - this.userData.set(userData); - } - - public boolean isInitialized() { - return getNode() != null; - } - - public boolean initializeIfNeeded() { - if (getNode() == null) { - if (getNodeSupplier() == null) { - return false; - } - setNode(getNodeSupplier().get()); - return true; - } - return false; - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java new file mode 100644 index 0000000000..4362848723 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.ui.construct; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.Node; +import org.jackhuang.hmcl.ui.animation.ContainerAnimations; +import org.jackhuang.hmcl.ui.animation.Motion; +import org.jackhuang.hmcl.ui.animation.TransitionPane; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/// Manages tab selection, lazy content initialization, and page lifecycle callbacks. +/// An optional transition pane displays content activated by [#selectTab(TabItem)]. +/// The first tab is selected when an unselected list gains tabs, without initializing +/// content. No control or skin is required. Changes must occur on the FX thread. +@NotNullByDefault +public final class TabController implements PageAware { + /// The optional destination for selected tab content. + private final @Nullable TransitionPane contentPane; + + /// The mutable tabs in display order. + private final ObservableList> tabs = FXCollections.observableArrayList(); + + /// Creates a controller with the supplied tabs and no content destination. + /// + /// @param tabs the initial tabs in display order, or null for an empty controller + public TabController(TabItem ... tabs) { + this(null, tabs); + } + + /// Creates a controller with an optional destination for selected content. + /// + /// @param contentPane the destination updated by [#selectTab(TabItem)], or null + /// @param tabs the initial tabs in display order, or null for an empty controller + public TabController(@Nullable TransitionPane contentPane, TabItem ... tabs) { + this.contentPane = contentPane; + this.tabs.addListener(this::tabsChanged); + this.tabs.setAll(tabs); + } + + /// Returns the mutable, observable list of tabs in display order. + /// Entries must be non-null and distinct. Inserting or reordering tabs preserves + /// an existing selection. If no tab is selected, the first tab is selected + /// without initializing content. + /// + /// Removing the selected tab hides its page and clears the content destination. + /// If tabs remain, the tab preceding the removed range is activated without + /// animation, or the first tab if there is no predecessor. If replacement + /// initialization fails, selection and content remain empty. Clearing the list also clears both. + public ObservableList> getTabs() { + return tabs; + } + + /// The selected tab, or null when no tab is selected. + private final ObjectProperty<@Nullable TabItem> selectedTab = new SimpleObjectProperty<>(this, "selectedTab"); + + /// Returns the read-only selected tab property; use [#selectTab(TabItem)] to activate a tab. + public ReadOnlyObjectProperty<@Nullable TabItem> selectedTabProperty() { + return selectedTab; + } + + /// Returns the selected tab, or null when no tab is selected. + /// A default selection does not imply that its content has been initialized. + public @Nullable TabItem getSelectedTab() { + return selectedTab.get(); + } + + /// Selects the tab and animates content replacement when content is already displayed. + /// + /// @see #selectTab(TabItem, boolean) + public void selectTab(TabItem tab) { + selectTab(tab, true); + } + + /// Initializes the tab's content, hides the previous page, replaces the configured + /// destination, updates selection, and shows the requested page, in that order. + /// Callbacks apply to nodes implementing [PageAware] and run even when the + /// requested tab is already selected. Without a destination, content may be null. + /// The previous tab is not initialized to deliver its hidden callback. + /// + /// Argument validation and initialization precede changes to the previous page. + /// Initialization failure leaves the previous selection and content unchanged. + /// Exceptions from subsequent callbacks or content replacement propagate without + /// rolling back completed effects. A shown callback runs after selection is updated. + /// + /// @param tab a tab belonging to [#getTabs()] + /// @param playAnimation whether to animate replacement of existing content + /// @throws NullPointerException if the tab is null + /// @throws IllegalArgumentException if the tab does not belong to this controller + /// @throws IllegalStateException if a content destination is configured but the + /// tab has no content after initialization + public void selectTab(TabItem tab, boolean playAnimation) { + Objects.requireNonNull(tab, "tab"); + if (!tabs.contains(tab)) + throw new IllegalArgumentException("Tab does not belong to this controller"); + + @Nullable TabItem previous = getSelectedTab(); + @Nullable Node previousContent = previous != null && previous.isInitialized() ? previous.getNode() : null; + @Nullable Node content = tab.getNode(); + if (contentPane != null && content == null) + throw new IllegalStateException("Tab has no content"); + + if (previousContent instanceof PageAware pageAware) + pageAware.onPageHidden(); + if (contentPane != null) { + if (playAnimation && contentPane.getCurrentNode() != null) { + contentPane.setContent(content, ContainerAnimations.SLIDE_UP_FADE_IN, + Motion.MEDIUM4, Motion.EASE_IN_OUT_CUBIC_EMPHASIZED); + } else { + contentPane.setContent(content, ContainerAnimations.NONE); + } + } + selectedTab.set(tab); + if (content instanceof PageAware pageAware) + pageAware.onPageShown(); + } + + /// Updates selection after list changes, activating a replacement only when selection was removed. + private void tabsChanged(ListChangeListener.Change> change) { + @Nullable TabItem selected = getSelectedTab(); + int removedIndex = 0; + while (change.next()) { + if (selected != null && change.getRemoved().contains(selected)) + removedIndex = change.getFrom(); + } + + if (selected == null) { + if (!tabs.isEmpty()) + selectedTab.set(tabs.get(0)); + } else if (!tabs.contains(selected)) { + // Clear the removed page first so a failing replacement cannot leave it selected. + clearSelection(); + if (!tabs.isEmpty()) + selectTab(tabs.get(Math.max(0, Math.min(removedIndex - 1, tabs.size() - 1))), false); + } + } + + /// Hides the selected page and clears selection and content, including when its hidden callback fails. + private void clearSelection() { + try { + onPageHidden(); + } finally { + try { + if (contentPane != null) + contentPane.clearContent(); + } finally { + selectedTab.set(null); + } + } + } + + /// Forwards the shown callback to initialized selected content implementing [PageAware]. + /// Does not initialize content. + @Override + public void onPageShown() { + @Nullable TabItem tab = getSelectedTab(); + if (tab != null && tab.isInitialized() && tab.getNode() instanceof PageAware pageAware) + pageAware.onPageShown(); + } + + /// Forwards the hidden callback to initialized selected content implementing [PageAware]. + /// Does not initialize content. + @Override + public void onPageHidden() { + @Nullable TabItem tab = getSelectedTab(); + if (tab != null && tab.isInitialized() && tab.getNode() instanceof PageAware pageAware) + pageAware.onPageHidden(); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabHeader.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabHeader.java index 576189883e..ee2e30ad64 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabHeader.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabHeader.java @@ -3,598 +3,300 @@ package org.jackhuang.hmcl.ui.construct; import javafx.animation.*; -import javafx.application.Platform; -import javafx.beans.binding.Bindings; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.collections.FXCollections; +import javafx.beans.property.*; +import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Side; -import javafx.scene.Node; +import javafx.scene.AccessibleAttribute; import javafx.scene.control.*; -import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; -import javafx.scene.transform.Rotate; -import javafx.scene.transform.Scale; import javafx.util.Duration; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.animation.ContainerAnimations; -import org.jackhuang.hmcl.ui.animation.Motion; -import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.util.javafx.MappedObservableList; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnmodifiableView; -@SuppressWarnings("deprecation") -public class TabHeader extends Control implements TabControl, PageAware { +/// Displays selectable tab headers backed by a [TabController]. +/// Activating a header delegates selection and content switching to its controller. +@NotNullByDefault +public class TabHeader extends Control { - private final TransitionPane contentPane; - - public TabHeader(Tab... tabs) { - this(null, tabs); - } - - public TabHeader(@Nullable TransitionPane contentPane, Tab... tabs) { - this.contentPane = contentPane; + /// The controller supplying the tab list and selection state. + private final TabController controller; + /// Creates a header with its own controller and no content destination. + /// + /// @param tabs the initial tabs in display order, or null for an empty header + public TabHeader(TabItem... tabs) { + this.controller = new TabController(tabs); getStyleClass().setAll("tab-header"); - if (tabs != null) { - getTabs().addAll(tabs); - } - } - - private final ObservableList> tabs = FXCollections.observableArrayList(); - - @Override - public ObservableList> getTabs() { - return tabs; } - private final SingleSelectionModel> selectionModel = new TabControlSelectionModel(this); - - public SingleSelectionModel> getSelectionModel() { - return selectionModel; - } - - public void select(Tab tab) { - select(tab, true); - } - - public void select(Tab tab, boolean playAnimation) { - Tab oldTab = getSelectionModel().getSelectedItem(); - if (oldTab != null) { - if (oldTab.getNode() instanceof PageAware pageAware) { - pageAware.onPageHidden(); - } - } - - tab.initializeIfNeeded(); - if (tab.getNode() instanceof PageAware pageAware) { - pageAware.onPageShown(); - } - - getSelectionModel().select(tab); - - if (contentPane != null) { - if (playAnimation && contentPane.getCurrentNode() != null) { - contentPane.setContent(tab.getNode(), - ContainerAnimations.SLIDE_UP_FADE_IN, - Motion.MEDIUM4, - Motion.EASE_IN_OUT_CUBIC_EMPHASIZED - ); - } else { - contentPane.setContent(tab.getNode(), ContainerAnimations.NONE); - } - } - } - - @Override - public void onPageShown() { - Tab tab = getSelectionModel().getSelectedItem(); - if (tab != null) { - if (tab.getNode() instanceof PageAware) { - ((PageAware) tab.getNode()).onPageShown(); - } - } - } - - @Override - public void onPageHidden() { - Tab tab = getSelectionModel().getSelectedItem(); - if (tab != null) { - if (tab.getNode() instanceof PageAware) { - ((PageAware) tab.getNode()).onPageHidden(); - } - } + /// Returns the controller used to activate tabs and manage content lifecycle. + public final TabController getController() { + return controller; } - private final ObjectProperty side = new SimpleObjectProperty<>(Side.TOP); + /// The lazily allocated tab placement property. + private @Nullable ObjectProperty side; - /** - * The position of the tabs. - */ + /// Returns the tab placement property, initially [Side#TOP]. Values must be non-null. public ObjectProperty sideProperty() { + if (side == null) + side = new SimpleObjectProperty<>(this, "side", Side.TOP); return side; } - /** - * The position to place the tabs. - */ + /// Returns the side along which the tab headers are arranged. public Side getSide() { - return side.get(); + return sideProperty().get(); } - /** - * The position the place the tabs in this TabHeader. - */ + /// Sets the side along which the tab headers are arranged. + /// + /// @param side the non-null tab placement public void setSide(Side side) { - this.side.set(side); + sideProperty().set(side); } + /// Creates the skin that lays out headers and animates the selection indicator. @Override protected Skin createDefaultSkin() { return new TabHeaderSkin(this); } + /// Arranges tab headers and animates the selection indicator along the chosen side. + @NotNullByDefault public static class TabHeaderSkin extends SkinBase { - + /// Marks the content of the selected tab for CSS styling. private static final PseudoClass SELECTED_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("selected"); - private final HeaderContainer header; - private boolean isSelectingTab = false; - private Tab selectedTab; - + /// The indicator, excluded from preferred-size calculations and mouse picking. + private final StackPane selectedTabLine = new StackPane(); + /// The tab views in the same order as the control's tabs. + private final @UnmodifiableView ObservableList headers; + /// Releases removed views and updates the layout children after list changes. + private final ListChangeListener headersListener = change -> { + while (change.next()) + change.getRemoved().forEach(TabHeaderView::dispose); + updateChildren(); + }; + /// The animated distance from the leading inset along the row or column. + private final DoubleProperty lineOffset = new SimpleDoubleProperty(); + /// The animated width or height of the indicator. + private final DoubleProperty lineLength = new SimpleDoubleProperty(); + /// The animation currently moving the indicator, or null when stationary. + private @Nullable Timeline timeline; + /// Whether the next layout should animate a selection change. + private boolean animateSelection; + /// The side used for the preceding layout, or null before the first layout. + private @Nullable Side previousSide; + /// The destination offset of the current indicator animation. + private double targetOffset; + /// The destination length of the current indicator animation. + private double targetLength; + + /// Creates tab views and an unmanaged indicator and observes selection and side changes. protected TabHeaderSkin(TabHeader control) { super(control); - - header = new HeaderContainer(); - getChildren().setAll(header); - - FXUtils.onChangeAndOperate(control.getSelectionModel().selectedItemProperty(), item -> { - isSelectingTab = true; - selectedTab = item; - Platform.runLater(() -> { - header.setNeedsLayout2(true); - header.layout(); - }); + selectedTabLine.getStyleClass().add("tab-selected-line"); + selectedTabLine.setManaged(false); + selectedTabLine.setMouseTransparent(true); + selectedTabLine.setVisible(false); + selectedTabLine.setPrefSize(2, 2); + lineOffset.addListener(observable -> control.requestLayout()); + lineLength.addListener(observable -> control.requestLayout()); + + headers = MappedObservableList.create(getSkinnable().controller.getTabs(), TabHeaderView::new); + headers.addListener(headersListener); + updateChildren(); + + registerChangeListener(control.getController().selectedTabProperty(), observable -> { + headers.forEach(TabHeaderView::updateSelection); + control.notifyAccessibleAttributeChanged(AccessibleAttribute.FOCUS_ITEM); + animateSelection = true; + control.requestLayout(); }); + registerChangeListener(control.sideProperty(), observable -> control.requestLayout()); - this.selectedTab = control.getSelectionModel().getSelectedItem(); - if (this.selectedTab == null && control.getSelectionModel().getSelectedIndex() != -1) { - control.getSelectionModel().select(control.getSelectionModel().getSelectedIndex()); - this.selectedTab = control.getSelectionModel().getSelectedItem(); - } - - if (this.selectedTab == null) { - control.getSelectionModel().selectFirst(); - } - - this.selectedTab = control.getSelectionModel().getSelectedItem(); } - protected final class HeaderContainer extends StackPane { - private Timeline timeline; - private final StackPane selectedTabLine; - private final HeadersRegion headersRegion; - private final Scale scale = new Scale(1, 1, 0, 0); - private final Rotate rotate = new Rotate(0, 0, 1); - @SuppressWarnings({"FieldCanBeLocal", "unused"}) - private final ObservableList binding; - - public HeaderContainer() { - getStyleClass().add("tab-header-area"); - setPickOnBounds(false); - - headersRegion = new HeadersRegion(); - headersRegion.sideProperty().bind(getSkinnable().sideProperty()); + /// Stops the indicator animation and releases tab bindings and listeners. + @Override + public void dispose() { + stopAnimation(); + headers.removeListener(headersListener); + headers.forEach(TabHeaderView::dispose); + getChildren().clear(); + super.dispose(); + } - selectedTabLine = new StackPane(); - selectedTabLine.setManaged(false); - selectedTabLine.getTransforms().addAll(scale, rotate); - selectedTabLine.setCache(true); - selectedTabLine.getStyleClass().addAll("tab-selected-line"); - selectedTabLine.setPrefHeight(2); - selectedTabLine.setPrefWidth(2); - getChildren().setAll(headersRegion, selectedTabLine); - headersRegion.setPickOnBounds(false); - headersRegion.prefHeightProperty().bind(heightProperty()); - rotate.pivotXProperty().bind(Bindings.createDoubleBinding(() -> getSkinnable().getSide().isHorizontal() ? 0.0 : 1, getSkinnable().sideProperty())); - rotate.pivotYProperty().bind(Bindings.createDoubleBinding(() -> getSkinnable().getSide().isHorizontal() ? 1.0 : 0, getSkinnable().sideProperty())); + /// Keeps the indicator above all tab views in paint order. + private void updateChildren() { + getChildren().setAll(headers); + getChildren().add(selectedTabLine); + getSkinnable().requestLayout(); + } - Bindings.bindContent(headersRegion.getChildren(), binding = MappedObservableList.create(getSkinnable().getTabs(), tab -> { - TabHeaderContainer container = new TabHeaderContainer(tab); - container.setVisible(true); - return container; - })); + /// Returns the widest tab's minimum width plus the control's horizontal insets. + @Override + protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + double width = 0; + for (TabHeaderView child : headers) { + if (!child.isManaged()) continue; + width = Math.max(width, snapSizeX(child.minWidth(-1))); } + return leftInset + width + rightInset; + } - public void setNeedsLayout2(boolean value) { - setNeedsLayout(value); + /// Returns the tallest tab's minimum height plus the control's vertical insets. + @Override + protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { + double height = 0; + for (TabHeaderView child : headers) { + if (!child.isManaged()) continue; + height = Math.max(height, snapSizeY(child.minHeight(-1))); } + return topInset + height + bottomInset; + } - private boolean isAnimating() { - return this.timeline != null && this.timeline.getStatus() == Animation.Status.RUNNING; + /// Returns the sum of tab widths for a row, or the widest tab for a column. + @Override + protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { + boolean horizontal = getSkinnable().getSide().isHorizontal(); + double width = 0; + for (TabHeaderView child : headers) { + if (!child.isVisible()) continue; + double childWidth = snapSizeX(child.prefWidth(height)); + width = horizontal ? width + childWidth : Math.max(width, childWidth); } + return leftInset + width + rightInset; + } - @Override - protected void layoutChildren() { - super.layoutChildren(); - - if (isSelectingTab) { - headersRegion.animateSelectionLine(); - isSelectingTab = false; - } + /// Returns the tallest tab for a row, or the sum of tab heights for a column. + @Override + protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { + boolean horizontal = getSkinnable().getSide().isHorizontal(); + double height = 0; + for (TabHeaderView child : headers) { + if (!child.isVisible()) continue; + double childHeight = snapSizeY(child.prefHeight(width)); + height = horizontal ? Math.max(height, childHeight) : height + childHeight; } + return topInset + height + bottomInset; + } - private final class HeadersRegion extends StackPane { - private SideAction action; - private final ObjectProperty side = new SimpleObjectProperty() { - @Override - protected void invalidated() { - super.invalidated(); - - action = switch (get()) { - case TOP -> new Top(); - case BOTTOM -> new Bottom(); - case LEFT -> new Left(); - case RIGHT -> new Right(); - }; - } - }; - - public Side getSide() { - return side.get(); - } - - public ObjectProperty sideProperty() { - return side; - } - - public void setSide(Side side) { - this.side.set(side); - } - - @Override - protected double computePrefWidth(double height) { - return action.computePrefWidth(height); - } - - @Override - protected double computePrefHeight(double width) { - return action.computePrefHeight(width); - } - - @Override - protected void layoutChildren() { - action.layoutChildren(); - } - - private void animateSelectionLine() { - action.animateSelectionLine(); - } - - private abstract class SideAction { - abstract double computePrefWidth(double height); - - abstract double computePrefHeight(double width); - - void layoutChildren() { - if (isSelectingTab) { - animateSelectionLine(); - isSelectingTab = false; - } - } - - abstract void animateSelectionLine(); - } - - private abstract class Horizontal extends SideAction { - @Override - public double computePrefWidth(double height) { - double width = 0; - for (Node child : getChildren()) { - if (!(child instanceof TabHeaderContainer) || !child.isVisible()) continue; - width += child.prefWidth(height); - } - return snapSize(width) + snappedLeftInset() + snappedRightInset(); - } - - @Override - public double computePrefHeight(double width) { - double height = 0; - for (Node child : getChildren()) { - if (!(child instanceof TabHeaderContainer) || !child.isVisible()) continue; - height = Math.max(height, child.prefHeight(width)); - } - return snapSize(height) + snappedTopInset() + snappedBottomInset(); - } - - private void runTimeline(double newTransX, double newWidth) { - double lineWidth = selectedTabLine.prefWidth(-1.0D); - if (isAnimating()) { - timeline.stop(); - double tempScaleX = scale.getX(); - if (rotate.getAngle() != 0.0D) { - rotate.setAngle(0.0D); - double tempWidth = tempScaleX * lineWidth; - selectedTabLine.setTranslateX(selectedTabLine.getTranslateX() - tempWidth); - } - } - - double oldScaleX = scale.getX(); - double oldWidth = lineWidth * oldScaleX; - double oldTransX = selectedTabLine.getTranslateX(); - double newScaleX = newWidth * oldScaleX / oldWidth; - // newTransX += offsetStart * (double)this.direction; - double transDiff = newTransX - oldTransX; - if (transDiff < 0.0D) { - selectedTabLine.setTranslateX(selectedTabLine.getTranslateX() + oldWidth); - newTransX += newWidth; - rotate.setAngle(180.0D); - } - - timeline = new Timeline( - new KeyFrame( - Duration.ZERO, - new KeyValue(selectedTabLine.translateXProperty(), selectedTabLine.getTranslateX(), Interpolator.EASE_BOTH) - ), - new KeyFrame( - Duration.seconds(0.24D), - new KeyValue(scale.xProperty(), newScaleX, Interpolator.EASE_BOTH), - new KeyValue(selectedTabLine.translateXProperty(), newTransX, Interpolator.EASE_BOTH) - ) - ); - timeline.setOnFinished((finish) -> { - if (rotate.getAngle() != 0.0D) { - rotate.setAngle(0.0D); - selectedTabLine.setTranslateX(selectedTabLine.getTranslateX() - newWidth); - } - - }); - timeline.play(); - } - - @Override - public void animateSelectionLine() { - double offset = 0.0D; - double selectedTabOffset = 0.0D; - double selectedTabWidth = 0.0D; - - for (Node node : headersRegion.getChildren()) { - if (node instanceof TabHeaderContainer) { - TabHeaderContainer tabHeader = (TabHeaderContainer) node; - double tabHeaderPrefWidth = snapSize(tabHeader.prefWidth(-1.0D)); - if (selectedTab != null && selectedTab.equals(tabHeader.tab)) { - selectedTabOffset = offset; - selectedTabWidth = tabHeaderPrefWidth; - break; - } - - offset += tabHeaderPrefWidth; - } - } - - this.runTimeline(selectedTabOffset, selectedTabWidth); - } - } - - private final class Top extends Horizontal { - @Override - public void layoutChildren() { - super.layoutChildren(); - - double headerHeight = snapSize(prefHeight(-1)); - double tabStartX = 0; - for (Node node : getChildren()) { - if (!(node instanceof TabHeaderContainer)) continue; - TabHeaderContainer child = (TabHeaderContainer) node; - double w = snapSize(child.prefWidth(-1)); - double h = snapSize(child.prefHeight(-1)); - child.resize(w, h); - - child.relocate(tabStartX, headerHeight - h - snappedBottomInset()); - tabStartX += w; - } - - selectedTabLine.resizeRelocate(0, - headerHeight - selectedTabLine.prefHeight(-1), - snapSize(selectedTabLine.prefWidth(-1)), - snapSize(selectedTabLine.prefHeight(-1))); - } - } - - private final class Bottom extends Horizontal { - @Override - public void layoutChildren() { - super.layoutChildren(); - - double headerHeight = snapSize(prefHeight(-1)); - double tabStartX = 0; - for (Node node : getChildren()) { - if (!(node instanceof TabHeaderContainer)) continue; - TabHeaderContainer child = (TabHeaderContainer) node; - double w = snapSize(child.prefWidth(-1)); - double h = snapSize(child.prefHeight(-1)); - child.resize(w, h); - - child.relocate(tabStartX, snappedTopInset()); - tabStartX += w; - } - - selectedTabLine.resizeRelocate(0, 0, - snapSize(selectedTabLine.prefWidth(-1)), - snapSize(selectedTabLine.prefHeight(-1))); - } - } - - private abstract class Vertical extends SideAction { - @Override - public double computePrefWidth(double height) { - double width = 0; - for (Node child : getChildren()) { - if (!(child instanceof TabHeaderContainer) || !child.isVisible()) continue; - width = Math.max(width, child.prefWidth(height)); - } - return snapSize(width) + snappedLeftInset() + snappedRightInset(); - } - - @Override - public double computePrefHeight(double width) { - double height = 0; - for (Node child : getChildren()) { - if (!(child instanceof TabHeaderContainer) || !child.isVisible()) continue; - height += child.prefHeight(width); - } - return snapSize(height) + snappedTopInset() + snappedBottomInset(); - } - - private void runTimeline(double newTransY, double newHeight) { - double lineHeight = selectedTabLine.prefHeight(-1.0D); - if (isAnimating()) { - timeline.stop(); - double tempScaleY = scale.getY(); - if (rotate.getAngle() != 0.0D) { - rotate.setAngle(0.0D); - double tempHeight = tempScaleY * lineHeight; - selectedTabLine.setTranslateY(selectedTabLine.getTranslateY() - tempHeight); - } - } - - double oldScaleY = scale.getY(); - double oldHeight = lineHeight * oldScaleY; - double oldTransY = selectedTabLine.getTranslateY(); - double newScaleY = newHeight * oldScaleY / oldHeight; - // newTransY += offsetStart * (double)this.direction; - double transDiff = newTransY - oldTransY; - if (transDiff < 0.0D) { - selectedTabLine.setTranslateY(selectedTabLine.getTranslateY() + oldHeight); - newTransY += newHeight; - rotate.setAngle(180.0D); - } - - timeline = new Timeline( - new KeyFrame( - Duration.ZERO, - new KeyValue(selectedTabLine.translateYProperty(), selectedTabLine.getTranslateY(), Interpolator.EASE_BOTH) - ), - new KeyFrame( - Duration.seconds(1.24D), - new KeyValue(scale.yProperty(), newScaleY, Interpolator.EASE_BOTH), - new KeyValue(selectedTabLine.translateYProperty(), newTransY, Interpolator.EASE_BOTH) - ) - ); - timeline.setOnFinished((finish) -> { - if (rotate.getAngle() != 0.0D) { - rotate.setAngle(0.0D); - selectedTabLine.setTranslateY(selectedTabLine.getTranslateY() - newHeight); - } - - }); - timeline.play(); - } - - @Override - public void animateSelectionLine() { - double offset = 0.0D; - double selectedTabOffset = 0.0D; - double selectedTabHeight = 0.0D; - - for (Node node : headersRegion.getChildren()) { - if (node instanceof TabHeaderContainer) { - TabHeaderContainer tabHeader = (TabHeaderContainer) node; - double tabHeaderPrefHeight = snapSize(tabHeader.prefHeight(-1.0D)); - if (selectedTab != null && selectedTab.equals(tabHeader.tab)) { - selectedTabOffset = offset; - selectedTabHeight = tabHeaderPrefHeight; - break; - } - - offset += tabHeaderPrefHeight; - } - } - - this.runTimeline(selectedTabOffset, selectedTabHeight); - } - } - - private final class Left extends Vertical { - @Override - public void layoutChildren() { - super.layoutChildren(); - - double headerWidth = snapSize(prefWidth(-1)); - double tabStartY = 0; - for (Node node : getChildren()) { - if (!(node instanceof TabHeaderContainer)) continue; - TabHeaderContainer child = (TabHeaderContainer) node; - double w = snapSize(child.prefWidth(-1)); - double h = snapSize(child.prefHeight(-1)); - child.resize(w, h); - - child.relocate(headerWidth - w - snappedRightInset(), tabStartY); - tabStartY += h; - } - - selectedTabLine.resizeRelocate(headerWidth - selectedTabLine.prefWidth(-1), 0, - snapSize(selectedTabLine.prefWidth(-1)), - snapSize(selectedTabLine.prefHeight(-1))); - } + /// Places tab views along the content-facing edge and updates the indicator bounds. + @Override + protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) { + Side side = getSkinnable().getSide(); + boolean horizontal = side.isHorizontal(); + double offset = 0; + double selectedOffset = 0; + double selectedLength = 0; + @Nullable TabItem selected = getSkinnable().getController().getSelectedTab(); + for (TabHeaderView child : headers) { + if (!child.isVisible()) continue; + double width = snapSizeX(child.prefWidth(-1)); + double height = snapSizeY(child.prefHeight(-1)); + double x = horizontal ? contentX + offset + : side == Side.LEFT ? contentX + contentWidth - width : contentX; + double y = !horizontal ? contentY + offset + : side == Side.TOP ? contentY + contentHeight - height : contentY; + child.resizeRelocate(x, y, width, height); + double length = horizontal ? width : height; + if (child.tab == selected) { + selectedOffset = offset; + selectedLength = length; } + offset += length; + } - private final class Right extends Vertical { - @Override - public void layoutChildren() { - super.layoutChildren(); - - double headerWidth = snapSize(prefWidth(-1)); - double tabStartY = 0; - for (Node node : getChildren()) { - if (!(node instanceof TabHeaderContainer)) continue; - TabHeaderContainer child = (TabHeaderContainer) node; - double w = snapSize(child.prefWidth(-1)); - double h = snapSize(child.prefHeight(-1)); - child.resize(w, h); - - child.relocate(snappedLeftInset(), tabStartY); - tabStartY += h; - } + updateSelectionLine(side, selectedOffset, selectedLength); + double thickness = horizontal ? snapSizeY(selectedTabLine.prefHeight(-1)) + : snapSizeX(selectedTabLine.prefWidth(-1)); + double x = horizontal ? contentX + lineOffset.get() + : side == Side.LEFT ? contentX + contentWidth - thickness : contentX; + double y = !horizontal ? contentY + lineOffset.get() + : side == Side.TOP ? contentY + contentHeight - thickness : contentY; + selectedTabLine.resizeRelocate(x, y, + horizontal ? lineLength.get() : thickness, + horizontal ? thickness : lineLength.get()); + } - selectedTabLine.resizeRelocate(0, 0, - snapSize(selectedTabLine.prefWidth(-1)), - snapSize(selectedTabLine.prefHeight(-1))); - } + /// Animates selection changes; applies initial, resized, and reoriented bounds immediately. + private void updateSelectionLine(Side side, double offset, double length) { + if (animateSelection || previousSide != side || targetOffset != offset || targetLength != length) { + boolean animate = animateSelection && previousSide == side + && selectedTabLine.isVisible() && length > 0; + // Capture the displayed bounds before stopping an interrupted animation. + double currentOffset = lineOffset.get(); + double currentLength = lineLength.get(); + stopAnimation(); + targetOffset = offset; + targetLength = length; + if (animate) { + lineOffset.set(currentOffset); + lineLength.set(currentLength); + timeline = new Timeline( + new KeyFrame(Duration.ZERO, + new KeyValue(lineOffset, currentOffset), + new KeyValue(lineLength, currentLength)), + new KeyFrame(Duration.seconds(side.isHorizontal() ? 0.24 : 1.24), + new KeyValue(lineOffset, offset, Interpolator.EASE_BOTH), + new KeyValue(lineLength, length, Interpolator.EASE_BOTH)) + ); + timeline.setOnFinished(event -> timeline = null); + timeline.play(); + } else { + lineOffset.set(offset); + lineLength.set(length); } - } + selectedTabLine.setVisible(length > 0); + previousSide = side; + animateSelection = false; } - protected class TabHeaderContainer extends StackPane { + /// Stops and releases the active indicator animation, if any. + private void stopAnimation() { + if (timeline != null) { + timeline.stop(); + timeline = null; + } + } - private final Tab tab; + /// Displays one tab's label, selection styling, and pointer feedback. + @NotNullByDefault + private final class TabHeaderView extends RipplerContainer { + /// The tab represented by this view. + private final TabItem tab; + /// The label bound to the tab's text. private final Label tabText; - private final BorderPane inner; - public TabHeaderContainer(Tab tab) { + /// Binds the label and selection styling and selects the tab when this view fires an action. + private TabHeaderView(TabItem tab) { + super(new Label()); this.tab = tab; - - tabText = new Label(); + this.tabText = (Label) getContainer(); tabText.textProperty().bind(tab.textProperty()); tabText.getStyleClass().add("tab-label"); - inner = new BorderPane(); - inner.setCenter(tabText); - inner.getStyleClass().add("tab-container"); - inner.setMouseTransparent(true); - RipplerContainer rippler = new RipplerContainer(inner); - getChildren().setAll(rippler); + tabText.setMouseTransparent(true); + updateSelection(); + setOnAction(event -> getSkinnable().getController().selectTab(tab)); + } - FXUtils.onChangeAndOperate(tab.selectedProperty(), selected -> inner.pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, selected)); + /// Reflects the controller's selected tab in the label's pseudo-class. + private void updateSelection() { + tabText.pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, + getSkinnable().getController().getSelectedTab() == tab); + } - FXUtils.onClicked(this, () -> { - this.setOpacity(1); - getSkinnable().getSelectionModel().select(tab); - }); + /// Releases the tab's text binding. + private void dispose() { + tabText.textProperty().unbind(); } } } - } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java new file mode 100644 index 0000000000..34c253ea59 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (C) 2020 The HMCL Authors +// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL +package org.jackhuang.hmcl.ui.construct; + +import javafx.beans.property.*; +import javafx.scene.Node; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Supplier; + +/// Stores a tab's label and optionally lazily created content. +/// +/// @param the type of content node +@NotNullByDefault +public final class TabItem { + private @Nullable T node; + private @Nullable Supplier nodeSupplier; + + /// Creates a tab with no text, content, or content factory. + public TabItem() { + } + + /// Creates a tab whose content is supplied lazily by [#initializeIfNeeded()]. + public TabItem(Supplier nodeSupplier) { + this.nodeSupplier = nodeSupplier; + } + + /// The text displayed in the header. + private @Nullable StringProperty text; + + /// Returns the writable header text property, whose value may be null. + public StringProperty textProperty() { + if (text == null) + text = new SimpleStringProperty(this, "text"); + return text; + } + + /// Returns the header text, which may be null. + public @Nullable String getText() { + return text != null ? text.get() : null; + } + + /// Sets the header text, which may be null. + public void setText(@Nullable String text) { + if (this.text != null || text != null) + textProperty().set(text); + } + + /// Returns the content node, invoking the factory if content has not been initialized. + /// Returns null if no factory is set or the factory returns null; later calls may retry. + /// Factory exceptions propagate without assigning content. + public @Nullable T getNode() { + initializeIfNeeded(); + return node; + } + + /// Returns whether a non-null content node is currently assigned. + public boolean isInitialized() { + return node != null; + } + + /// Invokes the content factory and assigns its result if the content is null + /// and a factory is present. A null result permits another attempt on the next call. + /// Factory exceptions propagate without assigning a result. + /// + /// @return true if the factory returned and its result was assigned, even if null; + /// false if content was already assigned or no factory was present + public boolean initializeIfNeeded() { + if (node == null) { + if (nodeSupplier == null) { + return false; + } + node = nodeSupplier.get(); + return true; + } + return false; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index f8af9d1458..88d1199c47 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java @@ -28,7 +28,8 @@ import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.construct.AdvancedListBox; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; -import org.jackhuang.hmcl.ui.construct.TabHeader; +import org.jackhuang.hmcl.ui.construct.TabController; +import org.jackhuang.hmcl.ui.construct.TabItem; import org.jackhuang.hmcl.ui.construct.Validator; import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; @@ -64,13 +65,20 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage (downloadProvider, repository, version, shader, file) -> download(downloadProvider, repository, version, file, "shaderpacks"); private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("download"), -1)); - private final TabHeader tab; - private final TabHeader.Tab newGameTab = new TabHeader.Tab<>("newGameTab"); - private final TabHeader.Tab modTab = new TabHeader.Tab<>("modTab"); - private final TabHeader.Tab modpackTab = new TabHeader.Tab<>("modpackTab"); - private final TabHeader.Tab resourcePackTab = new TabHeader.Tab<>("resourcePackTab"); - private final TabHeader.Tab shaderTab = new TabHeader.Tab<>("shaderTab"); - private final TabHeader.Tab worldTab = new TabHeader.Tab<>("worldTab"); + /// Manages tab selection and content transitions independently of the sidebar. + private final TabController tabController; + /// The tab for choosing a game version to install. + private final TabItem newGameTab; + /// The tab for browsing and importing modpacks. + private final TabItem modpackTab; + /// The tab for browsing downloadable mods. + private final TabItem modTab; + /// The tab for browsing downloadable resource packs. + private final TabItem resourcePackTab; + /// The tab for browsing downloadable shader packs. + private final TabItem shaderTab; + /// The tab for browsing downloadable worlds. + private final TabItem worldTab; private final TransitionPane transitionPane = new TransitionPane(); private final DownloadNavigator versionPageNavigator = new DownloadNavigator(); @@ -81,9 +89,13 @@ public DownloadPage() { } public DownloadPage(GameInstanceID uploadInstance) { - newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), GameVersionNumber.unknown(), DownloadProviders.getDownloadProvider(), - GameComponentType.GAME, versionPageNavigator::onGameSelected))); - modpackTab.setNodeSupplier(loadVersionFor(() -> { + this.newGameTab = new TabItem<>(() -> new VersionsPage( + versionPageNavigator, + i18n("install.installer.choose", i18n("install.installer.game")), + GameVersionNumber.unknown(), + DownloadProviders.getDownloadProvider(), + GameComponentType.GAME, versionPageNavigator::onGameSelected)); + this.modpackTab = newDownloadListTab(() -> { DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> { Instances.downloadModpackImpl(downloadProvider, repository, uploadInstance, modpack, file); }, false); @@ -93,40 +105,46 @@ public DownloadPage(GameInstanceID uploadInstance) { page.getActions().add(installLocalModpackButton); return page; - })); - modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod(FOR_MOD, true))); - resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack(FOR_RESOURCE_PACK, true))); - shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true))); - worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(RemoteAddon.Type.WORLD, CurseForgeRemoteAddonRepository.getInstance()))); - tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab); + }); + this.modTab = newDownloadListTab(() -> HMCLLocalizedDownloadListPage.ofMod(FOR_MOD, true)); + this.resourcePackTab = newDownloadListTab(() -> HMCLLocalizedDownloadListPage.ofResourcePack(FOR_RESOURCE_PACK, true)); + this.shaderTab = newDownloadListTab(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true)); + this.worldTab = newDownloadListTab(() -> new DownloadListPage(RemoteAddon.Type.WORLD, CurseForgeRemoteAddonRepository.getInstance())); + + this.tabController = new TabController(transitionPane, + newGameTab, + modpackTab, + modTab, + resourcePackTab, + shaderTab, + worldTab + ); GameDirectoryManager.registerVersionsListener(this::loadVersions); - tab.select(newGameTab); + tabController.selectTab(newGameTab); AdvancedListBox sideBar = new AdvancedListBox() .startCategory(i18n("download.game").toUpperCase(Locale.ROOT)) - .addNavigationDrawerTab(tab, newGameTab, i18n("game"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL) - .addNavigationDrawerTab(tab, modpackTab, i18n("modpack"), SVG.PACKAGE2, SVG.PACKAGE2_FILL) + .addNavigationDrawerTab(tabController, newGameTab, i18n("game"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL) + .addNavigationDrawerTab(tabController, modpackTab, i18n("modpack"), SVG.PACKAGE2, SVG.PACKAGE2_FILL) .startCategory(i18n("download.content").toUpperCase(Locale.ROOT)) - .addNavigationDrawerTab(tab, modTab, i18n("mods"), SVG.EXTENSION, SVG.EXTENSION_FILL) - .addNavigationDrawerTab(tab, resourcePackTab, i18n("resourcepack"), SVG.TEXTURE) - .addNavigationDrawerTab(tab, shaderTab, i18n("download.shader"), SVG.WB_SUNNY, SVG.WB_SUNNY_FILL) - .addNavigationDrawerTab(tab, worldTab, i18n("world"), SVG.PUBLIC); + .addNavigationDrawerTab(tabController, modTab, i18n("mods"), SVG.EXTENSION, SVG.EXTENSION_FILL) + .addNavigationDrawerTab(tabController, resourcePackTab, i18n("resourcepack"), SVG.TEXTURE) + .addNavigationDrawerTab(tabController, shaderTab, i18n("download.shader"), SVG.WB_SUNNY, SVG.WB_SUNNY_FILL) + .addNavigationDrawerTab(tabController, worldTab, i18n("world"), SVG.PUBLIC); FXUtils.setLimitWidth(sideBar, 200); setLeft(sideBar); setCenter(transitionPane); } - private static Supplier loadVersionFor(Supplier nodeSupplier) { - return () -> { - T node = nodeSupplier.get(); - if (node instanceof DownloadListPage page) { - page.loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository())); - } - return node; - }; + private static TabItem newDownloadListTab(Supplier pageSupplier) { + return new TabItem<>(() -> { + DownloadListPage page = pageSupplier.get(); + page.loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository())); + return page; + }); } public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) { @@ -197,25 +215,25 @@ public ReadOnlyObjectProperty stateProperty() { } public void showGameDownloads() { - tab.select(newGameTab, false); + tabController.selectTab(newGameTab, false); } public void showModpackDownloads() { - tab.select(modpackTab, false); + tabController.selectTab(modpackTab, false); } public DownloadListPage showResourcePackDownloads() { - tab.select(resourcePackTab, false); + tabController.selectTab(resourcePackTab, false); return resourcePackTab.getNode(); } public DownloadListPage showModDownloads() { - tab.select(modTab, false); + tabController.selectTab(modTab, false); return modTab.getNode(); } public void showWorldDownloads() { - tab.select(worldTab, false); + tabController.selectTab(worldTab, false); } private static final class DownloadNavigator implements Navigation { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java index 61fa020ec0..23b6afc7b4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java @@ -38,14 +38,22 @@ public class GameInstancePage extends DecoratorAnimatedPage implements DecoratorPage { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); - private final TabHeader tab; - private final TabHeader.Tab> gameSettingsTab = new TabHeader.Tab<>("versionSettingsTab"); - private final TabHeader.Tab installerListTab = new TabHeader.Tab<>("installerListTab"); - private final TabHeader.Tab modListTab = new TabHeader.Tab<>("modListTab"); - private final TabHeader.Tab worldListTab = new TabHeader.Tab<>("worldList"); - private final TabHeader.Tab schematicsTab = new TabHeader.Tab<>("schematicsTab"); - private final TabHeader.Tab resourcePackTab = new TabHeader.Tab<>("resourcePackTab"); - private final TabHeader.Tab screenshotsTab = new TabHeader.Tab<>("screenshotsTab"); + /// Manages tab selection and content transitions independently of the sidebar. + private final TabController tabController; + /// The settings tab bound to the current game instance. + private final TabItem> gameSettingsTab; + /// The component management tab bound to the current game instance. + private final TabItem installerListTab; + /// The installed mods tab bound to the current game instance. + private final TabItem modListTab; + /// The saved worlds tab bound to the current game instance. + private final TabItem worldListTab; + /// The schematics tab bound to the current game instance. + private final TabItem schematicsTab; + /// The installed resource packs tab bound to the current game instance. + private final TabItem resourcePackTab; + /// The screenshots tab bound to the current game instance. + private final TabItem screenshotsTab; private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty(); private final ObjectProperty instance = @@ -72,16 +80,23 @@ public WorkingDirChangedEvent() { public GameInstancePage() { // Child tabs subscribe to instanceProperty() themselves and reload on change. - gameSettingsTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Instance.class, instance)); - installerListTab.setNodeSupplier(() -> new InstallerListPage(instance)); - modListTab.setNodeSupplier(() -> new ModListPage(instance)); - resourcePackTab.setNodeSupplier(() -> new ResourcePackListPage(instance)); - worldListTab.setNodeSupplier(() -> new WorldListPage(instance)); - schematicsTab.setNodeSupplier(() -> new SchematicsPage(instance)); - screenshotsTab.setNodeSupplier(() -> new ScreenshotsPage(instance)); - - tab = new TabHeader(transitionPane, gameSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab, screenshotsTab); - tab.select(gameSettingsTab); + this.gameSettingsTab = new TabItem<>(() -> new GameSettingsPage<>(GameSettings.Instance.class, instance)); + this.installerListTab = new TabItem<>(() -> new InstallerListPage(instance)); + this.modListTab = new TabItem<>(() -> new ModListPage(instance)); + this.resourcePackTab = new TabItem<>(() -> new ResourcePackListPage(instance)); + this.worldListTab = new TabItem<>(() -> new WorldListPage(instance)); + this.schematicsTab = new TabItem<>(() -> new SchematicsPage(instance)); + this.screenshotsTab = new TabItem<>(() -> new ScreenshotsPage(instance)); + + tabController = new TabController(transitionPane, + gameSettingsTab, + installerListTab, + modListTab, + resourcePackTab, + worldListTab, + schematicsTab, + screenshotsTab); + tabController.selectTab(gameSettingsTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -152,7 +167,7 @@ private void checkSelectedInstance() { } public void showInstanceSettings() { - tab.select(gameSettingsTab, false); + tabController.selectTab(gameSettingsTab, false); } public void setInstance(GameInstanceID instanceId, HMCLGameRepository repository) { @@ -338,12 +353,12 @@ protected Skin(GameInstancePage control) { { AdvancedListBox sideBar = new AdvancedListBox() - .addNavigationDrawerTab(control.tab, control.gameSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL) - .addNavigationDrawerTab(control.tab, control.installerListTab, i18n("settings.tabs.installers"), SVG.DEPLOYED_CODE, SVG.DEPLOYED_CODE_FILL) - .addNavigationDrawerTab(control.tab, control.modListTab, i18n("mods.manage"), SVG.EXTENSION, SVG.EXTENSION_FILL) - .addNavigationDrawerTab(control.tab, control.resourcePackTab, i18n("resourcepack.manage"), SVG.TEXTURE) - .addNavigationDrawerTab(control.tab, control.worldListTab, i18n("world.manage"), SVG.PUBLIC) - .addNavigationDrawerTab(control.tab, control.schematicsTab, i18n("schematics.manage"), SVG.SCHEMA, SVG.SCHEMA_FILL); + .addNavigationDrawerTab(control.tabController, control.gameSettingsTab, i18n("settings.game"), SVG.SETTINGS, SVG.SETTINGS_FILL) + .addNavigationDrawerTab(control.tabController, control.installerListTab, i18n("settings.tabs.installers"), SVG.DEPLOYED_CODE, SVG.DEPLOYED_CODE_FILL) + .addNavigationDrawerTab(control.tabController, control.modListTab, i18n("mods.manage"), SVG.EXTENSION, SVG.EXTENSION_FILL) + .addNavigationDrawerTab(control.tabController, control.resourcePackTab, i18n("resourcepack.manage"), SVG.TEXTURE) + .addNavigationDrawerTab(control.tabController, control.worldListTab, i18n("world.manage"), SVG.PUBLIC) + .addNavigationDrawerTab(control.tabController, control.schematicsTab, i18n("schematics.manage"), SVG.SCHEMA, SVG.SCHEMA_FILL); VBox.setVgrow(sideBar, Priority.ALWAYS); PopupMenu browseList = new PopupMenu(); @@ -376,7 +391,7 @@ protected Skin(GameInstancePage control) { new MenuSeparator(), // Due to the sidebar height limit, we temporarily place the entries for these tabs // in the management menu; they will be moved back to the sidebar after a future UI refactor. - new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("screenshots.manage"), () -> control.tab.select(control.screenshotsTab), managementPopup), + new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("screenshots.manage"), () -> control.tabController.selectTab(control.screenshotsTab), managementPopup), new MenuSeparator(), new IconedMenuItem(null, i18n("instance.manage.redownload_assets_index"), control::redownloadAssetIndex, managementPopup), new IconedMenuItem(null, i18n("instance.manage.remove_assets"), control::clearAssets, managementPopup), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java index 33fe78abf4..6fb2cbd61e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java @@ -46,10 +46,14 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final BooleanProperty readOnly = new SimpleBooleanProperty(false); private final TransitionPane transitionPane = new TransitionPane(); - private final TabHeader header = new TabHeader(transitionPane); - private final TabHeader.Tab worldInfoTab = new TabHeader.Tab<>("worldInfoPage"); - private final TabHeader.Tab worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage"); - private final TabHeader.Tab dataPackTab = new TabHeader.Tab<>("dataPackListPage"); + /// Manages tab selection and content transitions independently of the sidebar. + private final TabController tabController = new TabController(transitionPane); + /// The tab displaying information about this world. + private final TabItem worldInfoTab; + /// The tab for managing backups of this world. + private final TabItem worldBackupsTab; + /// The tab for managing data packs in this world. + private final TabItem dataPackTab; public WorldManagePage(World world, HMCLGameInstance gameInstance) { this.world = world; @@ -65,9 +69,9 @@ public WorldManagePage(World world, HMCLGameInstance gameInstance) { this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> closePageForLoadingFail()); } - worldInfoTab.setNodeSupplier(() -> new WorldInfoPage(this)); - worldBackupsTab.setNodeSupplier(() -> new WorldBackupsPage(this)); - dataPackTab.setNodeSupplier(() -> new DataPackListPage(this)); + this.worldInfoTab = new TabItem<>(() -> new WorldInfoPage(this)); + this.worldBackupsTab = new TabItem<>(() -> new WorldBackupsPage(this)); + this.dataPackTab = new TabItem<>(() -> new DataPackListPage(this)); this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true)); @@ -82,6 +86,7 @@ public WorldManagePage(World world, HMCLGameInstance gameInstance) { return new Skin(this); } + /// Reloads world metadata and refreshes only tab pages whose content is already initialized. @Override public void refresh() { updateSessionLockChannel(); @@ -93,8 +98,8 @@ public void refresh() { return; } - for (var tab : header.getTabs()) { - if (tab.getNode() instanceof WorldRefreshable r) { + for (var tab : tabController.getTabs()) { + if (tab.isInitialized() && tab.getNode() instanceof WorldRefreshable r) { r.refresh(); } } @@ -192,15 +197,15 @@ private BorderPane getSidebar() { private AdvancedListBox getTabBar() { AdvancedListBox tabBar = new AdvancedListBox(); { - getSkinnable().header.getTabs().addAll(getSkinnable().worldInfoTab, getSkinnable().worldBackupsTab); - getSkinnable().header.select(getSkinnable().worldInfoTab); + getSkinnable().tabController.getTabs().addAll(getSkinnable().worldInfoTab, getSkinnable().worldBackupsTab); + getSkinnable().tabController.selectTab(getSkinnable().worldInfoTab); - tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) - .addNavigationDrawerTab(getSkinnable().header, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL); + tabBar.addNavigationDrawerTab(getSkinnable().tabController, getSkinnable().worldInfoTab, i18n("world.info"), SVG.INFO, SVG.INFO_FILL) + .addNavigationDrawerTab(getSkinnable().tabController, getSkinnable().worldBackupsTab, i18n("world.backup"), SVG.ARCHIVE, SVG.ARCHIVE_FILL); if (getSkinnable().world.supportDataPacks()) { - getSkinnable().header.getTabs().add(getSkinnable().dataPackTab); - tabBar.addNavigationDrawerTab(getSkinnable().header, getSkinnable().dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); + getSkinnable().tabController.getTabs().add(getSkinnable().dataPackTab); + tabBar.addNavigationDrawerTab(getSkinnable().tabController, getSkinnable().dataPackTab, i18n("world.datapack"), SVG.EXTENSION, SVG.EXTENSION_FILL); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java index 67f98ab1bd..5f24983e7d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java @@ -22,42 +22,59 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("settings"))); - private final TabHeader tab; - private final TabHeader.Tab> gameTab = new TabHeader.Tab<>("versionSettingsPage"); - private final TabControl.Tab javaManagementTab = new TabControl.Tab<>("javaManagementPage"); - private final TabHeader.Tab settingsTab = new TabHeader.Tab<>("settingsPage"); - private final TabHeader.Tab personalizationTab = new TabHeader.Tab<>("personalizationPage"); - private final TabHeader.Tab downloadTab = new TabHeader.Tab<>("downloadSettingsPage"); - private final TabHeader.Tab helpTab = new TabHeader.Tab<>("helpPage"); - private final TabHeader.Tab aboutTab = new TabHeader.Tab<>("aboutPage"); - private final TabHeader.Tab feedbackTab = new TabHeader.Tab<>("feedbackPage"); + /// Manages tab selection and content transitions independently of the sidebar. + private final TabController tabController; + /// The tab for editing game settings presets. + private final TabItem> gameTab; + /// The tab whose content manages installed Java runtimes. + private final TabItem javaManagementTab; + /// The tab for general launcher settings. + private final TabItem settingsTab; + /// The tab for launcher appearance settings. + private final TabItem personalizationTab; + /// The tab for download settings. + private final TabItem downloadTab; + /// The tab displaying launcher help. + private final TabItem helpTab; + /// The tab displaying launcher information. + private final TabItem aboutTab; + /// The tab displaying feedback and contact information. + private final TabItem feedbackTab; private final TransitionPane transitionPane = new TransitionPane(); public LauncherSettingsPage() { - gameTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Preset.class, null)); - javaManagementTab.setNodeSupplier(JavaManagementPage::new); - settingsTab.setNodeSupplier(SettingsPage::new); - personalizationTab.setNodeSupplier(PersonalizationPage::new); - downloadTab.setNodeSupplier(DownloadSettingsPage::new); - helpTab.setNodeSupplier(HelpPage::new); - feedbackTab.setNodeSupplier(FeedbackPage::new); - aboutTab.setNodeSupplier(AboutPage::new); - tab = new TabHeader(transitionPane, gameTab, javaManagementTab, settingsTab, personalizationTab, downloadTab, helpTab, feedbackTab, aboutTab); + gameTab = new TabItem<>(() -> new GameSettingsPage<>(GameSettings.Preset.class, null)); + javaManagementTab = new TabItem<>(JavaManagementPage::new); + settingsTab = new TabItem<>(SettingsPage::new); + personalizationTab = new TabItem<>(PersonalizationPage::new); + downloadTab = new TabItem<>(DownloadSettingsPage::new); + helpTab = new TabItem<>(HelpPage::new); + feedbackTab = new TabItem<>(FeedbackPage::new); + aboutTab = new TabItem<>(AboutPage::new); + tabController = new TabController(transitionPane, + gameTab, + javaManagementTab, + settingsTab, + personalizationTab, + downloadTab, + helpTab, + feedbackTab, + aboutTab); - tab.select(gameTab); + tabController.selectTab(gameTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()))); AdvancedListBox sideBar = new AdvancedListBox() - .addNavigationDrawerTab(tab, gameTab, i18n("settings.type.global.manage"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL) - .addNavigationDrawerTab(tab, javaManagementTab, i18n("java.management"), SVG.LOCAL_CAFE, SVG.LOCAL_CAFE_FILL) + .addNavigationDrawerTab(tabController, gameTab, i18n("settings.type.global.manage"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL) + .addNavigationDrawerTab(tabController, javaManagementTab, i18n("java.management"), SVG.LOCAL_CAFE, SVG.LOCAL_CAFE_FILL) .startCategory(i18n("launcher").toUpperCase(Locale.ROOT)) - .addNavigationDrawerTab(tab, settingsTab, i18n("settings.launcher.general"), SVG.TUNE) - .addNavigationDrawerTab(tab, personalizationTab, i18n("settings.launcher.appearance"), SVG.STYLE, SVG.STYLE_FILL) - .addNavigationDrawerTab(tab, downloadTab, i18n("download"), SVG.DOWNLOAD) + .addNavigationDrawerTab(tabController, settingsTab, i18n("settings.launcher.general"), SVG.TUNE) + .addNavigationDrawerTab(tabController, personalizationTab, i18n("settings.launcher.appearance"), SVG.STYLE, SVG.STYLE_FILL) + .addNavigationDrawerTab(tabController, downloadTab, i18n("download"), SVG.DOWNLOAD) .startCategory(i18n("help").toUpperCase(Locale.ROOT)) - .addNavigationDrawerTab(tab, helpTab, i18n("help"), SVG.HELP, SVG.HELP_FILL) - .addNavigationDrawerTab(tab, feedbackTab, i18n("contact"), SVG.FEEDBACK, SVG.FEEDBACK_FILL) - .addNavigationDrawerTab(tab, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL); + .addNavigationDrawerTab(tabController, helpTab, i18n("help"), SVG.HELP, SVG.HELP_FILL) + .addNavigationDrawerTab(tabController, feedbackTab, i18n("contact"), SVG.FEEDBACK, SVG.FEEDBACK_FILL) + .addNavigationDrawerTab(tabController, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL); FXUtils.setLimitWidth(sideBar, 200); setLeft(sideBar); @@ -66,21 +83,21 @@ public LauncherSettingsPage() { @Override public void onPageShown() { - tab.onPageShown(); + tabController.onPageShown(); } @Override public void onPageHidden() { - tab.onPageHidden(); + tabController.onPageHidden(); } public void showGameSettings(HMCLGameRepository repository) { gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); - tab.select(gameTab, false); + tabController.selectTab(gameTab, false); } public void showFeedback() { - tab.select(feedbackTab, false); + tabController.selectTab(feedbackTab, false); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java index 7caafc66a7..b2f46dd047 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java @@ -36,17 +36,19 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa private static final int TERRACOTTA_AGREEMENT_VERSION = 2; private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("terracotta.terracotta"))); - private final TabHeader tab; - private final TabHeader.Tab statusPage = new TabHeader.Tab<>("statusPage"); + /// Manages tab selection and content transitions independently of the sidebar. + private final TabController tabController; + /// The tab displaying the Terracotta controller page. + private final TabItem statusPage; private final TransitionPane transitionPane = new TransitionPane(); @SuppressWarnings("unused") private @Nullable ChangeListener<@Nullable HMCLGameInstance> instanceChangeListenerHolder; public TerracottaPage() { - statusPage.setNodeSupplier(TerracottaControllerPage::new); - tab = new TabHeader(transitionPane, statusPage); - tab.select(statusPage); + statusPage = new TabItem<>(TerracottaControllerPage::new); + tabController = new TabController(transitionPane, statusPage); + tabController.selectTab(statusPage); BorderPane left = new BorderPane(); FXUtils.setLimitWidth(left, 200); @@ -54,7 +56,7 @@ public TerracottaPage() { setLeft(left); AdvancedListBox sideBar = new AdvancedListBox() - .addNavigationDrawerTab(tab, statusPage, i18n("terracotta.status"), SVG.TUNE); + .addNavigationDrawerTab(tabController, statusPage, i18n("terracotta.status"), SVG.TUNE); left.setTop(sideBar); AccountAdvancedListItem accountListItem = new AccountAdvancedListItem(); @@ -98,7 +100,7 @@ public TerracottaPage() { @Override public void onPageShown() { - tab.onPageShown(); + tabController.onPageShown(); if (SettingsManager.userState().terracottaAgreementVersionProperty().get() < TERRACOTTA_AGREEMENT_VERSION) { Controllers.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5, MessageDialogPane.MessageType.INFO, () -> { @@ -110,7 +112,7 @@ public void onPageShown() { @Override public void onPageHidden() { - tab.onPageHidden(); + tabController.onPageHidden(); } @Override