From f848fa92260a93deb3e8c3a4c1146c37ee2564ed Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 27 Oct 2026 21:28:47 +0800 Subject: [PATCH 01/24] Merge TabControl into TabHeader Assisted-by: codex:gpt-6-astra --- .../hmcl/ui/account/CreateAccountPane.java | 6 +- .../hmcl/ui/construct/AdvancedListBox.java | 12 +- .../hmcl/ui/construct/TabControl.java | 262 --------------- .../hmcl/ui/construct/TabHeader.java | 315 +++++++++++++++++- .../hmcl/ui/main/LauncherSettingsPage.java | 3 +- 5 files changed, 325 insertions(+), 273 deletions(-) delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabControl.java 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..0b580b72bd 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,11 +133,11 @@ public CreateAccountPane(AccountFactory factory) { } if (showMethodSwitcher) { - TabControl.Tab[] tabs = new TabControl.Tab[Accounts.FACTORIES.size()]; - TabControl.Tab selected = null; + TabHeader.Tab[] tabs = new TabHeader.Tab[Accounts.FACTORIES.size()]; + @Nullable TabHeader.Tab 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] = new TabHeader.Tab<>(Accounts.getLoginType(f), Accounts.getLocalizedLoginTypeName(f)); tabs[i].setUserData(f); if (factory == f) { selected = tabs[i]; 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..f38b155f83 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,14 +78,21 @@ 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 selects the tab and tracks its selection state. + /// + /// @param leftGraphic the item icon, or null for no icon + /// @return this list box + public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabHeader.Tab 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)); return add(item); } - public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabControl.Tab tab, String title, + /// Adds an item that selects the tab and switches icons with its selection state. + /// + /// @return this list box + public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabHeader.Tab tab, String title, SVG unselectedGraphic, SVG selectedGraphic) { AdvancedListItem item = createNavigationDrawerItem(title, null); item.activeProperty().bind(tabHeader.getSelectionModel().selectedItemProperty().isEqualTo(tab)); 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/TabHeader.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabHeader.java index 576189883e..fe06fe96e6 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 @@ -5,12 +5,13 @@ import javafx.animation.*; import javafx.application.Platform; import javafx.beans.binding.Bindings; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.*; import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Side; +import javafx.scene.AccessibleAttribute; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.BorderPane; @@ -23,10 +24,14 @@ 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 java.util.function.Supplier; + +/// Displays selectable tab headers and optionally switches content in a transition pane. @SuppressWarnings("deprecation") -public class TabHeader extends Control implements TabControl, PageAware { +public class TabHeader extends Control implements PageAware { private final TransitionPane contentPane; @@ -45,12 +50,13 @@ public TabHeader(@Nullable TransitionPane contentPane, Tab... tabs) { private final ObservableList> tabs = FXCollections.observableArrayList(); - @Override + /// Returns the mutable, observable list of tabs in display order. public ObservableList> getTabs() { return tabs; } - private final SingleSelectionModel> selectionModel = new TabControlSelectionModel(this); + /// The selection model that tracks changes to the tab list. + private final SingleSelectionModel> selectionModel = new TabHeaderSelectionModel(this); public SingleSelectionModel> getSelectionModel() { return selectionModel; @@ -136,6 +142,305 @@ protected Skin createDefaultSkin() { return new TabHeaderSkin(this); } + /// Synchronizes a header's selected index, selected item, and tab selection flags. + /// + /// Changes to the tab list update the selection. Selecting through this model + /// does not initialize content or invoke page lifecycle callbacks. + @NotNullByDefault + public static class TabHeaderSelectionModel extends SingleSelectionModel> { + /// The header whose tab list supplies this model's items. + private final TabHeader tabHeader; + + /// Creates a selection model and registers a listener on the header's tab list. + /// + /// @param t the header to observe + /// @throws NullPointerException if the header is null + public TabHeaderSelectionModel(final TabHeader t) { + if (t == null) { + throw new NullPointerException("TabHeader cannot be null"); + } + this.tabHeader = t; + + // watching for changes to the items list content + final ListChangeListener> itemsContentObserver = c -> { + while (c.next()) { + for (@Nullable Tab tab : c.getRemoved()) { + if (tab != null && !tabHeader.getTabs().contains(tab)) { + if (tab.isSelected()) { + tab.setSelected(false); + final int tabIndex = c.getFrom(); + + // Select a nearby tab after removing the selected 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) { + // Select the first available tab when the list gains its first items. + findNearestAvailableTab(0, true); + } else if (tabHeader.getTabs().isEmpty()) { + clearSelection(); + } + }; + this.tabHeader.getTabs().addListener(itemsContentObserver); + } + + /// Selects the tab at the given index and notifies accessibility listeners. + /// Updates the selection flags of the old and new tabs. + @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); + + @Nullable 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); + } + + tabHeader.notifyAccessibleAttributeChanged(AccessibleAttribute.FOCUS_ITEM); + } + + /// Selects a matching tab in the list. If a non-null tab is absent, updates + /// only the selected item; a null argument leaves the selection unchanged. + @Override + public void select(@Nullable Tab tab) { + final int itemCount = getItemCount(); + + for (int i = 0; i < itemCount; i++) { + final @Nullable Tab value = getModelItem(i); + if (value != null && value.equals(tab)) { + select(i); + return; + } + } + if (tab != null) { + setSelectedItem(tab); + } + } + + /// Returns the tab at the given index, or null if the index is out of range. + @Override + protected @Nullable Tab getModelItem(int index) { + final ObservableList> items = tabHeader.getTabs(); + if (index < 0 || index >= items.size()) return null; + return items.get(index); + } + + /// Returns the current number of tabs in the header. + @Override + protected int getItemCount() { + final ObservableList> items = tabHeader.getTabs(); + return items.size(); + } + + /// Finds a tab near a removed position, searching left before right at each distance. + /// + /// @param tabIndex the position from which a tab was removed + /// @param doSelect whether to select the tab found + /// @return the nearest non-null tab, or null if none is available + private @Nullable Tab findNearestAvailableTab(int tabIndex, boolean doSelect) { + // Search outwards from the removed position, preferring the left side. + final int tabCount = getItemCount(); + int i = 1; + @Nullable Tab bestTab = null; + while (true) { + // look leftwards + int downPos = tabIndex - i; + if (downPos >= 0) { + @Nullable Tab candidate = getModelItem(downPos); + if (candidate != null) { + bestTab = candidate; + 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) { + @Nullable Tab candidate = getModelItem(upPos); + if (candidate != null) { + bestTab = candidate; + break; + } + } + + if (downPos < 0 && upPos >= tabCount) { + break; + } + i++; + } + + if (doSelect && bestTab != null) { + select(bestTab); + } + + return bestTab; + } + } + + /// Stores a tab's label, selection state, and optionally lazily created content. + /// + /// @param the type of content node + @NotNullByDefault + public static final class Tab { + /// The nullable identifier of this tab. + private final StringProperty id = new SimpleStringProperty(this, "id"); + /// The nullable text displayed in the header. + private final StringProperty text = new SimpleStringProperty(this, "text"); + /// Whether a selection model has marked this tab as selected. + private final ReadOnlyBooleanWrapper selected = new ReadOnlyBooleanWrapper(this, "selected"); + /// The content node, or null when no content is assigned. + private final ObjectProperty<@Nullable T> node = new SimpleObjectProperty<>(this, "node"); + /// Optional application data associated with this tab. + private final ObjectProperty<@Nullable Object> userData = new SimpleObjectProperty<>(this, "userData"); + /// The optional factory used when content has not been assigned. + private @Nullable Supplier nodeSupplier; + + /// Creates an unselected tab with the given nullable identifier and no text or content. + public Tab(@Nullable String id) { + setId(id); + } + + /// Creates an unselected tab with the given nullable identifier and text, and no content. + public Tab(@Nullable String id, @Nullable String text) { + setId(id); + setText(text); + } + + /// Returns the content factory, or null if none has been set. + public @Nullable Supplier getNodeSupplier() { + return nodeSupplier; + } + + /// Sets the content factory, or clears it if null. Existing content is unchanged. + public void setNodeSupplier(@Nullable Supplier nodeSupplier) { + this.nodeSupplier = nodeSupplier; + } + + /// Returns the tab identifier, which may be null. + public @Nullable String getId() { + return id.get(); + } + + /// Returns the writable identifier property, whose value may be null. + public StringProperty idProperty() { + return id; + } + + /// Sets the tab identifier, which may be null. + public void setId(@Nullable String id) { + this.id.set(id); + } + + /// Returns the header text, which may be null. + public @Nullable String getText() { + return text.get(); + } + + /// Returns the writable header text property, whose value may be null. + public StringProperty textProperty() { + return text; + } + + /// Sets the header text, which may be null. + public void setText(@Nullable String text) { + this.text.set(text); + } + + /// Returns whether this tab is marked as selected. + public boolean isSelected() { + return selected.get(); + } + + /// Returns a read-only view of the tab's selection state. + public ReadOnlyBooleanProperty selectedProperty() { + return selected.getReadOnlyProperty(); + } + + /// Updates the tab's selection flag. + private void setSelected(boolean selected) { + this.selected.set(selected); + } + + /// Returns the content node, or null if none is assigned; does not invoke the factory. + public @Nullable T getNode() { + return node.get(); + } + + /// Returns the writable content property. A null value permits lazy initialization. + public ObjectProperty<@Nullable T> nodeProperty() { + return node; + } + + /// Sets the content node, or clears it if null. Does not change the content factory. + public void setNode(@Nullable T node) { + this.node.set(node); + } + + /// Returns the application data, or null if none is assigned. + public @Nullable Object getUserData() { + return userData.get(); + } + + /// Returns the property holding nullable application data. + public ObjectProperty<@Nullable ?> userDataProperty() { + return userData; + } + + /// Sets the application data, which may be null. + public void setUserData(@Nullable Object userData) { + this.userData.set(userData); + } + + /// Returns whether a non-null content node is currently assigned. + public boolean isInitialized() { + return getNode() != 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 (getNode() == null) { + if (getNodeSupplier() == null) { + return false; + } + setNode(getNodeSupplier().get()); + return true; + } + return false; + } + } + public static class TabHeaderSkin extends SkinBase { private static final PseudoClass SELECTED_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("selected"); 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..9377a6be10 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 @@ -24,7 +24,8 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor 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"); + /// The tab whose content manages installed Java runtimes. + private final TabHeader.Tab javaManagementTab = new TabHeader.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"); From 626ae082d4df7bfbe0fcb56ce4e98a7edff7826c Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 27 Oct 2026 21:34:41 +0800 Subject: [PATCH 02/24] Refactor TabHeaderSelectionModel to improve tab selection logic and remove null checks --- .../hmcl/ui/construct/TabHeader.java | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) 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 fe06fe96e6..f551fce992 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 @@ -147,7 +147,7 @@ protected Skin createDefaultSkin() { /// Changes to the tab list update the selection. Selecting through this model /// does not initialize content or invoke page lifecycle callbacks. @NotNullByDefault - public static class TabHeaderSelectionModel extends SingleSelectionModel> { + private static class TabHeaderSelectionModel extends SingleSelectionModel<@Nullable Tab> { /// The header whose tab list supplies this model's items. private final TabHeader tabHeader; @@ -156,9 +156,6 @@ public static class TabHeaderSelectionModel extends SingleSelectionModel> /// @param t the header to observe /// @throws NullPointerException if the header is null public TabHeaderSelectionModel(final TabHeader t) { - if (t == null) { - throw new NullPointerException("TabHeader cannot be null"); - } this.tabHeader = t; // watching for changes to the items list content @@ -171,7 +168,7 @@ public TabHeaderSelectionModel(final TabHeader t) { final int tabIndex = c.getFrom(); // Select a nearby tab after removing the selected tab. - findNearestAvailableTab(tabIndex, true); + selectNearestAvailableTab(tabIndex); } } } @@ -183,9 +180,9 @@ public TabHeaderSelectionModel(final TabHeader t) { } } } - if (getSelectedIndex() == -1 && getSelectedItem() == null && tabHeader.getTabs().size() > 0) { + if (getSelectedIndex() == -1 && getSelectedItem() == null && !tabHeader.getTabs().isEmpty()) { // Select the first available tab when the list gains its first items. - findNearestAvailableTab(0, true); + selectNearestAvailableTab(0); } else if (tabHeader.getTabs().isEmpty()) { clearSelection(); } @@ -255,12 +252,10 @@ protected int getItemCount() { return items.size(); } - /// Finds a tab near a removed position, searching left before right at each distance. + /// Select the tab near a removed position, searching left before right at each distance. /// /// @param tabIndex the position from which a tab was removed - /// @param doSelect whether to select the tab found - /// @return the nearest non-null tab, or null if none is available - private @Nullable Tab findNearestAvailableTab(int tabIndex, boolean doSelect) { + private void selectNearestAvailableTab(int tabIndex) { // Search outwards from the removed position, preferring the left side. final int tabCount = getItemCount(); int i = 1; @@ -296,11 +291,9 @@ protected int getItemCount() { i++; } - if (doSelect && bestTab != null) { + if (bestTab != null) { select(bestTab); } - - return bestTab; } } From 2d541cca93897921f364dc7ab2ba32df0e4f8d53 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 27 Oct 2026 21:46:38 +0800 Subject: [PATCH 03/24] Simplify TabHeader skin layout and selection animation Assisted-by: codex:gpt-6-astra --- .../hmcl/ui/construct/TabHeader.java | 638 ++++++------------ 1 file changed, 201 insertions(+), 437 deletions(-) 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 f551fce992..1e0de4508e 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,8 +3,7 @@ package org.jackhuang.hmcl.ui.construct; import javafx.animation.*; -import javafx.application.Platform; -import javafx.beans.binding.Bindings; +import javafx.beans.InvalidationListener; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; @@ -16,8 +15,6 @@ 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; @@ -26,11 +23,11 @@ import org.jackhuang.hmcl.util.javafx.MappedObservableList; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnmodifiableView; import java.util.function.Supplier; /// Displays selectable tab headers and optionally switches content in a transition pane. -@SuppressWarnings("deprecation") public class TabHeader extends Control implements PageAware { private final TransitionPane contentPane; @@ -56,7 +53,7 @@ public ObservableList> getTabs() { } /// The selection model that tracks changes to the tab list. - private final SingleSelectionModel> selectionModel = new TabHeaderSelectionModel(this); + private final SingleSelectionModel> selectionModel = new TabHeaderSelectionModel(); public SingleSelectionModel> getSelectionModel() { return selectionModel; @@ -147,22 +144,14 @@ protected Skin createDefaultSkin() { /// Changes to the tab list update the selection. Selecting through this model /// does not initialize content or invoke page lifecycle callbacks. @NotNullByDefault - private static class TabHeaderSelectionModel extends SingleSelectionModel<@Nullable Tab> { - /// The header whose tab list supplies this model's items. - private final TabHeader tabHeader; - - /// Creates a selection model and registers a listener on the header's tab list. - /// - /// @param t the header to observe - /// @throws NullPointerException if the header is null - public TabHeaderSelectionModel(final TabHeader t) { - this.tabHeader = t; - + private final class TabHeaderSelectionModel extends SingleSelectionModel<@Nullable Tab> { + /// Registers a listener that keeps the selection aligned with this header's tab list. + private TabHeaderSelectionModel() { // watching for changes to the items list content final ListChangeListener> itemsContentObserver = c -> { while (c.next()) { for (@Nullable Tab tab : c.getRemoved()) { - if (tab != null && !tabHeader.getTabs().contains(tab)) { + if (tab != null && !getTabs().contains(tab)) { if (tab.isSelected()) { tab.setSelected(false); final int tabIndex = c.getFrom(); @@ -175,19 +164,19 @@ public TabHeaderSelectionModel(final TabHeader t) { 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() != getTabs().indexOf(getSelectedItem())) { + clearAndSelect(getTabs().indexOf(getSelectedItem())); } } } - if (getSelectedIndex() == -1 && getSelectedItem() == null && !tabHeader.getTabs().isEmpty()) { + if (getSelectedIndex() == -1 && getSelectedItem() == null && !getTabs().isEmpty()) { // Select the first available tab when the list gains its first items. selectNearestAvailableTab(0); - } else if (tabHeader.getTabs().isEmpty()) { + } else if (getTabs().isEmpty()) { clearSelection(); } }; - this.tabHeader.getTabs().addListener(itemsContentObserver); + getTabs().addListener(itemsContentObserver); } /// Selects the tab at the given index and notifies accessibility listeners. @@ -200,8 +189,8 @@ public void select(int index) { } // Unselect the old tab - if (getSelectedIndex() >= 0 && getSelectedIndex() < tabHeader.getTabs().size()) { - tabHeader.getTabs().get(getSelectedIndex()).setSelected(false); + if (getSelectedIndex() >= 0 && getSelectedIndex() < getTabs().size()) { + getTabs().get(getSelectedIndex()).setSelected(false); } setSelectedIndex(index); @@ -212,11 +201,11 @@ public void select(int index) { } // Select the new tab - if (getSelectedIndex() >= 0 && getSelectedIndex() < tabHeader.getTabs().size()) { - tabHeader.getTabs().get(getSelectedIndex()).setSelected(true); + if (getSelectedIndex() >= 0 && getSelectedIndex() < getTabs().size()) { + getTabs().get(getSelectedIndex()).setSelected(true); } - tabHeader.notifyAccessibleAttributeChanged(AccessibleAttribute.FOCUS_ITEM); + notifyAccessibleAttributeChanged(AccessibleAttribute.FOCUS_ITEM); } /// Selects a matching tab in the list. If a non-null tab is absent, updates @@ -240,7 +229,7 @@ public void select(@Nullable Tab tab) { /// Returns the tab at the given index, or null if the index is out of range. @Override protected @Nullable Tab getModelItem(int index) { - final ObservableList> items = tabHeader.getTabs(); + final ObservableList> items = getTabs(); if (index < 0 || index >= items.size()) return null; return items.get(index); } @@ -248,7 +237,7 @@ public void select(@Nullable Tab tab) { /// Returns the current number of tabs in the header. @Override protected int getItemCount() { - final ObservableList> items = tabHeader.getTabs(); + final ObservableList> items = getTabs(); return items.size(); } @@ -434,465 +423,240 @@ public boolean initializeIfNeeded() { } } + /// 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"); + /// The single layout container for tab views and the selection indicator. private final HeaderContainer header; - private boolean isSelectingTab = false; - private Tab selectedTab; + /// Creates tab views and observes the control's selection and side. 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(); - }); + registerChangeListener(control.getSelectionModel().selectedItemProperty(), observable -> { + header.animateSelection = true; + header.requestLayout(); }); + registerChangeListener(control.sideProperty(), observable -> header.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(); + if (control.getSelectionModel().getSelectedItem() == null) { + if (control.getSelectionModel().getSelectedIndex() != -1) + control.getSelectionModel().select(control.getSelectionModel().getSelectedIndex()); + if (control.getSelectionModel().getSelectedItem() == 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() { + /// Stops the indicator animation and releases tab bindings and listeners. + @Override + public void dispose() { + header.stopAnimation(); + header.headers.removeListener(header.headersListener); + header.headers.forEach(TabHeaderContainer::dispose); + getChildren().clear(); + super.dispose(); + } + + /// Lays out tab views and their selection indicator along one edge. + @NotNullByDefault + private final class HeaderContainer extends StackPane { + /// 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(TabHeaderContainer::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 selection indicator. + private HeaderContainer() { getStyleClass().add("tab-header-area"); setPickOnBounds(false); - - headersRegion = new HeadersRegion(); - headersRegion.sideProperty().bind(getSkinnable().sideProperty()); - - selectedTabLine = new StackPane(); + selectedTabLine.getStyleClass().add("tab-selected-line"); 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())); - - Bindings.bindContent(headersRegion.getChildren(), binding = MappedObservableList.create(getSkinnable().getTabs(), tab -> { - TabHeaderContainer container = new TabHeaderContainer(tab); - container.setVisible(true); - return container; - })); + selectedTabLine.setMouseTransparent(true); + selectedTabLine.setVisible(false); + selectedTabLine.setPrefSize(2, 2); + lineOffset.addListener(observable -> requestLayout()); + lineLength.addListener(observable -> requestLayout()); + + headers = MappedObservableList.create(getSkinnable().getTabs(), TabHeaderContainer::new); + headers.addListener(headersListener); + updateChildren(); } - public void setNeedsLayout2(boolean value) { - setNeedsLayout(value); - } - - private boolean isAnimating() { - return this.timeline != null && this.timeline.getStatus() == Animation.Status.RUNNING; + /// Keeps the indicator above all tab views in paint order. + private void updateChildren() { + getChildren().setAll(headers); + getChildren().add(selectedTabLine); + requestLayout(); } + /// Returns the sum of tab widths for a row, or the widest tab for a column. @Override - protected void layoutChildren() { - super.layoutChildren(); - - if (isSelectingTab) { - headersRegion.animateSelectionLine(); - isSelectingTab = false; + protected double computePrefWidth(double height) { + boolean horizontal = getSkinnable().getSide().isHorizontal(); + double width = 0; + for (TabHeaderContainer child : headers) { + if (!child.isVisible()) continue; + double childWidth = snapSizeX(child.prefWidth(height)); + width = horizontal ? width + childWidth : Math.max(width, childWidth); } + return width + snappedLeftInset() + snappedRightInset(); } - 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))); - } + /// Returns the tallest tab for a row, or the sum of tab heights for a column. + @Override + protected double computePrefHeight(double width) { + boolean horizontal = getSkinnable().getSide().isHorizontal(); + double height = 0; + for (TabHeaderContainer child : headers) { + if (!child.isVisible()) continue; + double childHeight = snapSizeY(child.prefHeight(width)); + height = horizontal ? Math.max(height, childHeight) : height + childHeight; } + return height + snappedTopInset() + snappedBottomInset(); + } - 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))); + /// Places tab views along the content-facing edge and updates the indicator bounds. + @Override + protected void layoutChildren() { + Side side = getSkinnable().getSide(); + boolean horizontal = side.isHorizontal(); + double offset = 0; + double selectedOffset = 0; + double selectedLength = 0; + @Nullable Tab selected = getSkinnable().getSelectionModel().getSelectedItem(); + for (TabHeaderContainer child : headers) { + if (!child.isVisible()) continue; + double width = snapSizeX(child.prefWidth(-1)); + double height = snapSizeY(child.prefHeight(-1)); + double x = horizontal ? snappedLeftInset() + offset + : side == Side.LEFT ? getWidth() - snappedRightInset() - width : snappedLeftInset(); + double y = !horizontal ? snappedTopInset() + offset + : side == Side.TOP ? getHeight() - snappedBottomInset() - height : snappedTopInset(); + child.resizeRelocate(x, y, width, height); + double length = horizontal ? width : height; + if (child.tab == selected) { + selectedOffset = offset; + selectedLength = length; } + offset += length; } - 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); - } + updateSelectionLine(side, selectedOffset, selectedLength); + double thickness = horizontal ? snapSizeY(selectedTabLine.prefHeight(-1)) + : snapSizeX(selectedTabLine.prefWidth(-1)); + double x = horizontal ? snappedLeftInset() + lineOffset.get() + : side == Side.LEFT ? getWidth() - snappedRightInset() - thickness : snappedLeftInset(); + double y = !horizontal ? snappedTopInset() + lineOffset.get() + : side == Side.TOP ? getHeight() - snappedBottomInset() - thickness : snappedTopInset(); + selectedTabLine.resizeRelocate(x, y, + horizontal ? lineLength.get() : thickness, + horizontal ? thickness : lineLength.get()); + } + /// 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(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) - ) + 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((finish) -> { - if (rotate.getAngle() != 0.0D) { - rotate.setAngle(0.0D); - selectedTabLine.setTranslateY(selectedTabLine.getTranslateY() - newHeight); - } - - }); + timeline.setOnFinished(event -> timeline = null); 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); + } else { + lineOffset.set(offset); + lineLength.set(length); } } + selectedTabLine.setVisible(length > 0); + previousSide = side; + animateSelection = false; + } - 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))); - } - } - - 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; - } - - selectedTabLine.resizeRelocate(0, 0, - snapSize(selectedTabLine.prefWidth(-1)), - snapSize(selectedTabLine.prefHeight(-1))); - } + /// Stops and releases the active indicator animation, if any. + private void stopAnimation() { + if (timeline != null) { + timeline.stop(); + timeline = null; } - } } - protected class TabHeaderContainer extends StackPane { - + /// Displays one tab's label, selection styling, and pointer feedback. + @NotNullByDefault + private final class TabHeaderContainer extends StackPane { + /// The tab represented by this view. private final Tab tab; - private final Label tabText; - private final BorderPane inner; - - public TabHeaderContainer(Tab tab) { + /// The label bound to the tab's text. + private final Label tabText = new Label(); + /// The content node receiving the selection pseudo-class. + private final BorderPane inner = new BorderPane(); + /// Updates selection styling until this view is removed or the skin is disposed. + private final InvalidationListener selectionListener = observable -> updateSelection(); + + /// Binds the label and selection styling and installs pointer selection. + private TabHeaderContainer(Tab tab) { this.tab = tab; - - tabText = new Label(); 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); + getChildren().setAll(new RipplerContainer(inner)); + tab.selectedProperty().addListener(selectionListener); + updateSelection(); + FXUtils.onClicked(this, () -> getSkinnable().getSelectionModel().select(tab)); + } - FXUtils.onChangeAndOperate(tab.selectedProperty(), selected -> inner.pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, selected)); + /// Reflects the tab's selection flag in the content pseudo-class. + private void updateSelection() { + inner.pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, tab.isSelected()); + } - FXUtils.onClicked(this, () -> { - this.setOpacity(1); - getSkinnable().getSelectionModel().select(tab); - }); + /// Releases the tab's text binding and selection listener. + private void dispose() { + tabText.textProperty().unbind(); + tab.selectedProperty().removeListener(selectionListener); } } } - } From c3fe9346893441f0ee20ca1ed2fa4b32203d2899 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 27 Oct 2026 21:50:54 +0800 Subject: [PATCH 04/24] Extract TabItem from TabHeader into a top-level class Assisted-by: codex:gpt-6-astra --- .../hmcl/ui/account/CreateAccountPane.java | 6 +- .../hmcl/ui/construct/AdvancedListBox.java | 4 +- .../hmcl/ui/construct/TabHeader.java | 215 ++++-------------- .../jackhuang/hmcl/ui/construct/TabItem.java | 147 ++++++++++++ .../hmcl/ui/download/DownloadPage.java | 19 +- .../hmcl/ui/instances/GameInstancePage.java | 21 +- .../hmcl/ui/instances/WorldManagePage.java | 9 +- .../hmcl/ui/main/LauncherSettingsPage.java | 23 +- .../hmcl/ui/terracotta/TerracottaPage.java | 3 +- 9 files changed, 250 insertions(+), 197 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java 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 0b580b72bd..035c9c9854 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,11 +133,11 @@ public CreateAccountPane(AccountFactory factory) { } if (showMethodSwitcher) { - TabHeader.Tab[] tabs = new TabHeader.Tab[Accounts.FACTORIES.size()]; - @Nullable TabHeader.Tab selected = null; + TabItem[] tabs = new TabItem[Accounts.FACTORIES.size()]; + @Nullable TabItem selected = null; for (int i = 0; i < tabs.length; i++) { AccountFactory f = Accounts.FACTORIES.get(i); - tabs[i] = new TabHeader.Tab<>(Accounts.getLoginType(f), Accounts.getLocalizedLoginTypeName(f)); + tabs[i] = new TabItem<>(Accounts.getLoginType(f), Accounts.getLocalizedLoginTypeName(f)); tabs[i].setUserData(f); if (factory == f) { selected = tabs[i]; 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 f38b155f83..aa8ece75ef 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 @@ -82,7 +82,7 @@ public AdvancedListBox addNavigationDrawerItem(String title, SVG leftGraphic, Ru /// /// @param leftGraphic the item icon, or null for no icon /// @return this list box - public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabHeader.Tab tab, String title, @Nullable SVG leftGraphic) { + public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, 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)); @@ -92,7 +92,7 @@ public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabHeader.Tab /// Adds an item that selects the tab and switches icons with its selection state. /// /// @return this list box - public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabHeader.Tab tab, String title, + public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabItem tab, String title, SVG unselectedGraphic, SVG selectedGraphic) { AdvancedListItem item = createNavigationDrawerItem(title, null); item.activeProperty().bind(tabHeader.getSelectionModel().selectedItemProperty().isEqualTo(tab)); 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 1e0de4508e..94cc4697e2 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 @@ -11,7 +11,6 @@ import javafx.css.PseudoClass; import javafx.geometry.Side; import javafx.scene.AccessibleAttribute; -import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; @@ -25,18 +24,24 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.UnmodifiableView; -import java.util.function.Supplier; - /// Displays selectable tab headers and optionally switches content in a transition pane. public class TabHeader extends Control implements PageAware { - private final TransitionPane contentPane; + /// The optional destination for selected tab content. + private final @Nullable TransitionPane contentPane; - public TabHeader(Tab... tabs) { + /// Creates a header with the supplied tabs and no content destination. + /// + /// @param tabs the initial tabs in display order, or null for an empty header + public TabHeader(TabItem @Nullable ... tabs) { this(null, tabs); } - public TabHeader(@Nullable TransitionPane contentPane, Tab... tabs) { + /// Creates a header with an optional destination for selected content. + /// + /// @param contentPane the destination updated by [#select(TabItem)], or null + /// @param tabs the initial tabs in display order, or null for an empty header + public TabHeader(@Nullable TransitionPane contentPane, TabItem @Nullable ... tabs) { this.contentPane = contentPane; getStyleClass().setAll("tab-header"); @@ -45,26 +50,37 @@ public TabHeader(@Nullable TransitionPane contentPane, Tab... tabs) { } } - private final ObservableList> tabs = FXCollections.observableArrayList(); + /// The mutable tabs in display order. + private final ObservableList> tabs = FXCollections.observableArrayList(); /// Returns the mutable, observable list of tabs in display order. - public ObservableList> getTabs() { + public ObservableList> getTabs() { return tabs; } /// The selection model that tracks changes to the tab list. - private final SingleSelectionModel> selectionModel = new TabHeaderSelectionModel(); + private final SingleSelectionModel<@Nullable TabItem> selectionModel = new TabHeaderSelectionModel(); - public SingleSelectionModel> getSelectionModel() { + /// Returns the selection model; selecting through it does not switch content. + public SingleSelectionModel<@Nullable TabItem> getSelectionModel() { return selectionModel; } - public void select(Tab tab) { + /// Selects the tab and switches its content with animation when content is already displayed. + /// + /// @see #select(TabItem, boolean) + public void select(TabItem tab) { select(tab, true); } - public void select(Tab tab, boolean playAnimation) { - Tab oldTab = getSelectionModel().getSelectedItem(); + /// Hides the previous page, initializes and shows the requested page, updates the + /// selection, and replaces the content of the configured transition pane. + /// Page callbacks are invoked even when the requested tab is already selected. + /// + /// @param tab the tab to select + /// @param playAnimation whether to animate replacement of existing content + public void select(TabItem tab, boolean playAnimation) { + @Nullable TabItem oldTab = getSelectionModel().getSelectedItem(); if (oldTab != null) { if (oldTab.getNode() instanceof PageAware pageAware) { pageAware.onPageHidden(); @@ -91,9 +107,10 @@ public void select(Tab tab, boolean playAnimation) { } } + /// Forwards the shown callback to the selected content when it implements [PageAware]. @Override public void onPageShown() { - Tab tab = getSelectionModel().getSelectedItem(); + @Nullable TabItem tab = getSelectionModel().getSelectedItem(); if (tab != null) { if (tab.getNode() instanceof PageAware) { ((PageAware) tab.getNode()).onPageShown(); @@ -101,9 +118,10 @@ public void onPageShown() { } } + /// Forwards the hidden callback to the selected content when it implements [PageAware]. @Override public void onPageHidden() { - Tab tab = getSelectionModel().getSelectedItem(); + @Nullable TabItem tab = getSelectionModel().getSelectedItem(); if (tab != null) { if (tab.getNode() instanceof PageAware) { ((PageAware) tab.getNode()).onPageHidden(); @@ -144,13 +162,13 @@ protected Skin createDefaultSkin() { /// Changes to the tab list update the selection. Selecting through this model /// does not initialize content or invoke page lifecycle callbacks. @NotNullByDefault - private final class TabHeaderSelectionModel extends SingleSelectionModel<@Nullable Tab> { + private final class TabHeaderSelectionModel extends SingleSelectionModel<@Nullable TabItem> { /// Registers a listener that keeps the selection aligned with this header's tab list. private TabHeaderSelectionModel() { // watching for changes to the items list content - final ListChangeListener> itemsContentObserver = c -> { + final ListChangeListener> itemsContentObserver = c -> { while (c.next()) { - for (@Nullable Tab tab : c.getRemoved()) { + for (@Nullable TabItem tab : c.getRemoved()) { if (tab != null && !getTabs().contains(tab)) { if (tab.isSelected()) { tab.setSelected(false); @@ -195,7 +213,7 @@ public void select(int index) { setSelectedIndex(index); - @Nullable Tab tab = getModelItem(index); + @Nullable TabItem tab = getModelItem(index); if (tab != null) { setSelectedItem(tab); } @@ -211,11 +229,11 @@ public void select(int index) { /// Selects a matching tab in the list. If a non-null tab is absent, updates /// only the selected item; a null argument leaves the selection unchanged. @Override - public void select(@Nullable Tab tab) { + public void select(@Nullable TabItem tab) { final int itemCount = getItemCount(); for (int i = 0; i < itemCount; i++) { - final @Nullable Tab value = getModelItem(i); + final @Nullable TabItem value = getModelItem(i); if (value != null && value.equals(tab)) { select(i); return; @@ -228,8 +246,8 @@ public void select(@Nullable Tab tab) { /// Returns the tab at the given index, or null if the index is out of range. @Override - protected @Nullable Tab getModelItem(int index) { - final ObservableList> items = getTabs(); + protected @Nullable TabItem getModelItem(int index) { + final ObservableList> items = getTabs(); if (index < 0 || index >= items.size()) return null; return items.get(index); } @@ -237,7 +255,7 @@ public void select(@Nullable Tab tab) { /// Returns the current number of tabs in the header. @Override protected int getItemCount() { - final ObservableList> items = getTabs(); + final ObservableList> items = getTabs(); return items.size(); } @@ -248,12 +266,12 @@ private void selectNearestAvailableTab(int tabIndex) { // Search outwards from the removed position, preferring the left side. final int tabCount = getItemCount(); int i = 1; - @Nullable Tab bestTab = null; + @Nullable TabItem bestTab = null; while (true) { // look leftwards int downPos = tabIndex - i; if (downPos >= 0) { - @Nullable Tab candidate = getModelItem(downPos); + @Nullable TabItem candidate = getModelItem(downPos); if (candidate != null) { bestTab = candidate; break; @@ -267,7 +285,7 @@ private void selectNearestAvailableTab(int tabIndex) { // the removed tabs position). int upPos = tabIndex + i - 1; if (upPos < tabCount) { - @Nullable Tab candidate = getModelItem(upPos); + @Nullable TabItem candidate = getModelItem(upPos); if (candidate != null) { bestTab = candidate; break; @@ -286,143 +304,6 @@ private void selectNearestAvailableTab(int tabIndex) { } } - /// Stores a tab's label, selection state, and optionally lazily created content. - /// - /// @param the type of content node - @NotNullByDefault - public static final class Tab { - /// The nullable identifier of this tab. - private final StringProperty id = new SimpleStringProperty(this, "id"); - /// The nullable text displayed in the header. - private final StringProperty text = new SimpleStringProperty(this, "text"); - /// Whether a selection model has marked this tab as selected. - private final ReadOnlyBooleanWrapper selected = new ReadOnlyBooleanWrapper(this, "selected"); - /// The content node, or null when no content is assigned. - private final ObjectProperty<@Nullable T> node = new SimpleObjectProperty<>(this, "node"); - /// Optional application data associated with this tab. - private final ObjectProperty<@Nullable Object> userData = new SimpleObjectProperty<>(this, "userData"); - /// The optional factory used when content has not been assigned. - private @Nullable Supplier nodeSupplier; - - /// Creates an unselected tab with the given nullable identifier and no text or content. - public Tab(@Nullable String id) { - setId(id); - } - - /// Creates an unselected tab with the given nullable identifier and text, and no content. - public Tab(@Nullable String id, @Nullable String text) { - setId(id); - setText(text); - } - - /// Returns the content factory, or null if none has been set. - public @Nullable Supplier getNodeSupplier() { - return nodeSupplier; - } - - /// Sets the content factory, or clears it if null. Existing content is unchanged. - public void setNodeSupplier(@Nullable Supplier nodeSupplier) { - this.nodeSupplier = nodeSupplier; - } - - /// Returns the tab identifier, which may be null. - public @Nullable String getId() { - return id.get(); - } - - /// Returns the writable identifier property, whose value may be null. - public StringProperty idProperty() { - return id; - } - - /// Sets the tab identifier, which may be null. - public void setId(@Nullable String id) { - this.id.set(id); - } - - /// Returns the header text, which may be null. - public @Nullable String getText() { - return text.get(); - } - - /// Returns the writable header text property, whose value may be null. - public StringProperty textProperty() { - return text; - } - - /// Sets the header text, which may be null. - public void setText(@Nullable String text) { - this.text.set(text); - } - - /// Returns whether this tab is marked as selected. - public boolean isSelected() { - return selected.get(); - } - - /// Returns a read-only view of the tab's selection state. - public ReadOnlyBooleanProperty selectedProperty() { - return selected.getReadOnlyProperty(); - } - - /// Updates the tab's selection flag. - private void setSelected(boolean selected) { - this.selected.set(selected); - } - - /// Returns the content node, or null if none is assigned; does not invoke the factory. - public @Nullable T getNode() { - return node.get(); - } - - /// Returns the writable content property. A null value permits lazy initialization. - public ObjectProperty<@Nullable T> nodeProperty() { - return node; - } - - /// Sets the content node, or clears it if null. Does not change the content factory. - public void setNode(@Nullable T node) { - this.node.set(node); - } - - /// Returns the application data, or null if none is assigned. - public @Nullable Object getUserData() { - return userData.get(); - } - - /// Returns the property holding nullable application data. - public ObjectProperty<@Nullable ?> userDataProperty() { - return userData; - } - - /// Sets the application data, which may be null. - public void setUserData(@Nullable Object userData) { - this.userData.set(userData); - } - - /// Returns whether a non-null content node is currently assigned. - public boolean isInitialized() { - return getNode() != 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 (getNode() == null) { - if (getNodeSupplier() == null) { - return false; - } - setNode(getNodeSupplier().get()); - return true; - } - return false; - } - } - /// Arranges tab headers and animates the selection indicator along the chosen side. @NotNullByDefault public static class TabHeaderSkin extends SkinBase { @@ -548,7 +429,7 @@ protected void layoutChildren() { double offset = 0; double selectedOffset = 0; double selectedLength = 0; - @Nullable Tab selected = getSkinnable().getSelectionModel().getSelectedItem(); + @Nullable TabItem selected = getSkinnable().getSelectionModel().getSelectedItem(); for (TabHeaderContainer child : headers) { if (!child.isVisible()) continue; double width = snapSizeX(child.prefWidth(-1)); @@ -625,7 +506,7 @@ private void stopAnimation() { @NotNullByDefault private final class TabHeaderContainer extends StackPane { /// The tab represented by this view. - private final Tab tab; + private final TabItem tab; /// The label bound to the tab's text. private final Label tabText = new Label(); /// The content node receiving the selection pseudo-class. @@ -634,7 +515,7 @@ private final class TabHeaderContainer extends StackPane { private final InvalidationListener selectionListener = observable -> updateSelection(); /// Binds the label and selection styling and installs pointer selection. - private TabHeaderContainer(Tab tab) { + private TabHeaderContainer(TabItem tab) { this.tab = tab; tabText.textProperty().bind(tab.textProperty()); tabText.getStyleClass().add("tab-label"); 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..d009f54cdd --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java @@ -0,0 +1,147 @@ +// 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, selection state, and optionally lazily created content. +/// +/// @param the type of content node +@NotNullByDefault +public final class TabItem { + /// The nullable identifier of this tab. + private final StringProperty id = new SimpleStringProperty(this, "id"); + /// The nullable text displayed in the header. + private final StringProperty text = new SimpleStringProperty(this, "text"); + /// Whether a selection model has marked this tab as selected. + private final ReadOnlyBooleanWrapper selected = new ReadOnlyBooleanWrapper(this, "selected"); + /// The content node, or null when no content is assigned. + private final ObjectProperty<@Nullable T> node = new SimpleObjectProperty<>(this, "node"); + /// Optional application data associated with this tab. + private final ObjectProperty<@Nullable Object> userData = new SimpleObjectProperty<>(this, "userData"); + /// The optional factory used when content has not been assigned. + private @Nullable Supplier nodeSupplier; + + /// Creates an unselected tab with the given nullable identifier and no text or content. + public TabItem(@Nullable String id) { + setId(id); + } + + /// Creates an unselected tab with the given nullable identifier and text, and no content. + public TabItem(@Nullable String id, @Nullable String text) { + setId(id); + setText(text); + } + + /// Returns the content factory, or null if none has been set. + public @Nullable Supplier getNodeSupplier() { + return nodeSupplier; + } + + /// Sets the content factory, or clears it if null. Existing content is unchanged. + public void setNodeSupplier(@Nullable Supplier nodeSupplier) { + this.nodeSupplier = nodeSupplier; + } + + /// Returns the tab identifier, which may be null. + public @Nullable String getId() { + return id.get(); + } + + /// Returns the writable identifier property, whose value may be null. + public StringProperty idProperty() { + return id; + } + + /// Sets the tab identifier, which may be null. + public void setId(@Nullable String id) { + this.id.set(id); + } + + /// Returns the header text, which may be null. + public @Nullable String getText() { + return text.get(); + } + + /// Returns the writable header text property, whose value may be null. + public StringProperty textProperty() { + return text; + } + + /// Sets the header text, which may be null. + public void setText(@Nullable String text) { + this.text.set(text); + } + + /// Returns whether this tab is marked as selected. + public boolean isSelected() { + return selected.get(); + } + + /// Returns a read-only view of the tab's selection state. + public ReadOnlyBooleanProperty selectedProperty() { + return selected.getReadOnlyProperty(); + } + + /// Updates the tab's selection flag. + void setSelected(boolean selected) { + this.selected.set(selected); + } + + /// Returns the content node, or null if none is assigned; does not invoke the factory. + public @Nullable T getNode() { + return node.get(); + } + + /// Returns the writable content property. A null value permits lazy initialization. + public ObjectProperty<@Nullable T> nodeProperty() { + return node; + } + + /// Sets the content node, or clears it if null. Does not change the content factory. + public void setNode(@Nullable T node) { + this.node.set(node); + } + + /// Returns the application data, or null if none is assigned. + public @Nullable Object getUserData() { + return userData.get(); + } + + /// Returns the property holding nullable application data. + public ObjectProperty<@Nullable ?> userDataProperty() { + return userData; + } + + /// Sets the application data, which may be null. + public void setUserData(@Nullable Object userData) { + this.userData.set(userData); + } + + /// Returns whether a non-null content node is currently assigned. + public boolean isInitialized() { + return getNode() != 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 (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/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index f8af9d1458..8773d55448 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 @@ -29,6 +29,7 @@ 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.TabItem; import org.jackhuang.hmcl.ui.construct.Validator; import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; @@ -65,12 +66,18 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage 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"); + /// The tab for choosing a game version to install. + private final TabItem newGameTab = new TabItem<>("newGameTab"); + /// The tab for browsing downloadable mods. + private final TabItem modTab = new TabItem<>("modTab"); + /// The tab for browsing and importing modpacks. + private final TabItem modpackTab = new TabItem<>("modpackTab"); + /// The tab for browsing downloadable resource packs. + private final TabItem resourcePackTab = new TabItem<>("resourcePackTab"); + /// The tab for browsing downloadable shader packs. + private final TabItem shaderTab = new TabItem<>("shaderTab"); + /// The tab for browsing downloadable worlds. + private final TabItem worldTab = new TabItem<>("worldTab"); private final TransitionPane transitionPane = new TransitionPane(); private final DownloadNavigator versionPageNavigator = new DownloadNavigator(); 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..9fa4ba3573 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 @@ -39,13 +39,20 @@ 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"); + /// The settings tab bound to the current game instance. + private final TabItem> gameSettingsTab = new TabItem<>("versionSettingsTab"); + /// The component management tab bound to the current game instance. + private final TabItem installerListTab = new TabItem<>("installerListTab"); + /// The installed mods tab bound to the current game instance. + private final TabItem modListTab = new TabItem<>("modListTab"); + /// The saved worlds tab bound to the current game instance. + private final TabItem worldListTab = new TabItem<>("worldList"); + /// The schematics tab bound to the current game instance. + private final TabItem schematicsTab = new TabItem<>("schematicsTab"); + /// The installed resource packs tab bound to the current game instance. + private final TabItem resourcePackTab = new TabItem<>("resourcePackTab"); + /// The screenshots tab bound to the current game instance. + private final TabItem screenshotsTab = new TabItem<>("screenshotsTab"); private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty(); private final ObjectProperty instance = 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..89b34653f8 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 @@ -47,9 +47,12 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco 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"); + /// The tab displaying information about this world. + private final TabItem worldInfoTab = new TabItem<>("worldInfoPage"); + /// The tab for managing backups of this world. + private final TabItem worldBackupsTab = new TabItem<>("worldBackupsPage"); + /// The tab for managing data packs in this world. + private final TabItem dataPackTab = new TabItem<>("dataPackListPage"); public WorldManagePage(World world, HMCLGameInstance gameInstance) { this.world = world; 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 9377a6be10..f9b012bda8 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 @@ -23,15 +23,22 @@ 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"); + /// The tab for editing game settings presets. + private final TabItem> gameTab = new TabItem<>("versionSettingsPage"); /// The tab whose content manages installed Java runtimes. - private final TabHeader.Tab javaManagementTab = new TabHeader.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"); + private final TabItem javaManagementTab = new TabItem<>("javaManagementPage"); + /// The tab for general launcher settings. + private final TabItem settingsTab = new TabItem<>("settingsPage"); + /// The tab for launcher appearance settings. + private final TabItem personalizationTab = new TabItem<>("personalizationPage"); + /// The tab for download settings. + private final TabItem downloadTab = new TabItem<>("downloadSettingsPage"); + /// The tab displaying launcher help. + private final TabItem helpTab = new TabItem<>("helpPage"); + /// The tab displaying launcher information. + private final TabItem aboutTab = new TabItem<>("aboutPage"); + /// The tab displaying feedback and contact information. + private final TabItem feedbackTab = new TabItem<>("feedbackPage"); private final TransitionPane transitionPane = new TransitionPane(); public LauncherSettingsPage() { 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..1f6448c894 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 @@ -37,7 +37,8 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("terracotta.terracotta"))); private final TabHeader tab; - private final TabHeader.Tab statusPage = new TabHeader.Tab<>("statusPage"); + /// The tab displaying the Terracotta controller page. + private final TabItem statusPage = new TabItem<>("statusPage"); private final TransitionPane transitionPane = new TransitionPane(); @SuppressWarnings("unused") From 7ac252ba2e679c64291ef2e073209dc9285f96e3 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 27 Oct 2026 21:57:49 +0800 Subject: [PATCH 05/24] Merge HeaderContainer into TabHeaderSkin Assisted-by: codex:gpt-6-astra --- .../hmcl/ui/construct/TabHeader.java | 315 +++++++++--------- 1 file changed, 161 insertions(+), 154 deletions(-) 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 94cc4697e2..661ba340e6 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 @@ -310,20 +310,51 @@ 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"); - /// The single layout container for tab views and the selection indicator. - private final HeaderContainer header; - - /// Creates tab views and observes the control's selection and side. + /// 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(TabHeaderContainer::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); + 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().getTabs(), TabHeaderContainer::new); + headers.addListener(headersListener); + updateChildren(); registerChangeListener(control.getSelectionModel().selectedItemProperty(), observable -> { - header.animateSelection = true; - header.requestLayout(); + animateSelection = true; + control.requestLayout(); }); - registerChangeListener(control.sideProperty(), observable -> header.requestLayout()); + registerChangeListener(control.sideProperty(), observable -> control.requestLayout()); if (control.getSelectionModel().getSelectedItem() == null) { if (control.getSelectionModel().getSelectedIndex() != -1) @@ -336,169 +367,145 @@ protected TabHeaderSkin(TabHeader control) { /// Stops the indicator animation and releases tab bindings and listeners. @Override public void dispose() { - header.stopAnimation(); - header.headers.removeListener(header.headersListener); - header.headers.forEach(TabHeaderContainer::dispose); + stopAnimation(); + headers.removeListener(headersListener); + headers.forEach(TabHeaderContainer::dispose); getChildren().clear(); super.dispose(); } - /// Lays out tab views and their selection indicator along one edge. - @NotNullByDefault - private final class HeaderContainer extends StackPane { - /// 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(TabHeaderContainer::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 selection indicator. - private HeaderContainer() { - getStyleClass().add("tab-header-area"); - setPickOnBounds(false); - selectedTabLine.getStyleClass().add("tab-selected-line"); - selectedTabLine.setManaged(false); - selectedTabLine.setMouseTransparent(true); - selectedTabLine.setVisible(false); - selectedTabLine.setPrefSize(2, 2); - lineOffset.addListener(observable -> requestLayout()); - lineLength.addListener(observable -> requestLayout()); - - headers = MappedObservableList.create(getSkinnable().getTabs(), TabHeaderContainer::new); - headers.addListener(headersListener); - updateChildren(); + /// Keeps the indicator above all tab views in paint order. + private void updateChildren() { + getChildren().setAll(headers); + getChildren().add(selectedTabLine); + getSkinnable().requestLayout(); + } + + /// 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 (TabHeaderContainer child : headers) { + if (!child.isManaged()) continue; + width = Math.max(width, snapSizeX(child.minWidth(-1))); } + return leftInset + width + rightInset; + } - /// Keeps the indicator above all tab views in paint order. - private void updateChildren() { - getChildren().setAll(headers); - getChildren().add(selectedTabLine); - requestLayout(); + /// 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 (TabHeaderContainer child : headers) { + if (!child.isManaged()) continue; + height = Math.max(height, snapSizeY(child.minHeight(-1))); } + return topInset + height + bottomInset; + } - /// Returns the sum of tab widths for a row, or the widest tab for a column. - @Override - protected double computePrefWidth(double height) { - boolean horizontal = getSkinnable().getSide().isHorizontal(); - double width = 0; - for (TabHeaderContainer child : headers) { - if (!child.isVisible()) continue; - double childWidth = snapSizeX(child.prefWidth(height)); - width = horizontal ? width + childWidth : Math.max(width, childWidth); - } - return width + snappedLeftInset() + snappedRightInset(); + /// 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 (TabHeaderContainer child : headers) { + if (!child.isVisible()) continue; + double childWidth = snapSizeX(child.prefWidth(height)); + width = horizontal ? width + childWidth : Math.max(width, childWidth); } + return leftInset + width + rightInset; + } - /// Returns the tallest tab for a row, or the sum of tab heights for a column. - @Override - protected double computePrefHeight(double width) { - boolean horizontal = getSkinnable().getSide().isHorizontal(); - double height = 0; - for (TabHeaderContainer child : headers) { - if (!child.isVisible()) continue; - double childHeight = snapSizeY(child.prefHeight(width)); - height = horizontal ? Math.max(height, childHeight) : height + childHeight; - } - return height + snappedTopInset() + snappedBottomInset(); + /// 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 (TabHeaderContainer child : headers) { + if (!child.isVisible()) continue; + double childHeight = snapSizeY(child.prefHeight(width)); + height = horizontal ? Math.max(height, childHeight) : height + childHeight; } + return topInset + height + bottomInset; + } - /// Places tab views along the content-facing edge and updates the indicator bounds. - @Override - protected void layoutChildren() { - Side side = getSkinnable().getSide(); - boolean horizontal = side.isHorizontal(); - double offset = 0; - double selectedOffset = 0; - double selectedLength = 0; - @Nullable TabItem selected = getSkinnable().getSelectionModel().getSelectedItem(); - for (TabHeaderContainer child : headers) { - if (!child.isVisible()) continue; - double width = snapSizeX(child.prefWidth(-1)); - double height = snapSizeY(child.prefHeight(-1)); - double x = horizontal ? snappedLeftInset() + offset - : side == Side.LEFT ? getWidth() - snappedRightInset() - width : snappedLeftInset(); - double y = !horizontal ? snappedTopInset() + offset - : side == Side.TOP ? getHeight() - snappedBottomInset() - height : snappedTopInset(); - child.resizeRelocate(x, y, width, height); - double length = horizontal ? width : height; - if (child.tab == selected) { - selectedOffset = offset; - selectedLength = length; - } - offset += length; + /// 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().getSelectionModel().getSelectedItem(); + for (TabHeaderContainer 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; } - - updateSelectionLine(side, selectedOffset, selectedLength); - double thickness = horizontal ? snapSizeY(selectedTabLine.prefHeight(-1)) - : snapSizeX(selectedTabLine.prefWidth(-1)); - double x = horizontal ? snappedLeftInset() + lineOffset.get() - : side == Side.LEFT ? getWidth() - snappedRightInset() - thickness : snappedLeftInset(); - double y = !horizontal ? snappedTopInset() + lineOffset.get() - : side == Side.TOP ? getHeight() - snappedBottomInset() - thickness : snappedTopInset(); - selectedTabLine.resizeRelocate(x, y, - horizontal ? lineLength.get() : thickness, - horizontal ? thickness : lineLength.get()); + offset += length; } - /// 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); - } + 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()); + } + + /// 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; } + selectedTabLine.setVisible(length > 0); + previousSide = side; + animateSelection = false; + } - /// Stops and releases the active indicator animation, if any. - private void stopAnimation() { - if (timeline != null) { - timeline.stop(); - timeline = null; - } + /// Stops and releases the active indicator animation, if any. + private void stopAnimation() { + if (timeline != null) { + timeline.stop(); + timeline = null; } } From e23b959f590dc94d2ae6b3f6b68eccae79e04f7c Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 27 Oct 2026 22:00:14 +0800 Subject: [PATCH 06/24] Refactor TabItem initialization to remove unnecessary identifiers --- .../hmcl/ui/account/CreateAccountPane.java | 3 ++- .../jackhuang/hmcl/ui/construct/TabItem.java | 26 +------------------ .../hmcl/ui/download/DownloadPage.java | 12 ++++----- .../hmcl/ui/instances/GameInstancePage.java | 14 +++++----- .../hmcl/ui/instances/WorldManagePage.java | 6 ++--- .../hmcl/ui/main/LauncherSettingsPage.java | 16 ++++++------ .../hmcl/ui/terracotta/TerracottaPage.java | 2 +- 7 files changed, 28 insertions(+), 51 deletions(-) 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 035c9c9854..1c88f49f0c 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 @@ -137,7 +137,8 @@ public CreateAccountPane(AccountFactory factory) { @Nullable TabItem selected = null; for (int i = 0; i < tabs.length; i++) { AccountFactory f = Accounts.FACTORIES.get(i); - tabs[i] = new TabItem<>(Accounts.getLoginType(f), Accounts.getLocalizedLoginTypeName(f)); + tabs[i] = new TabItem<>(); + tabs[i].setText(Accounts.getLocalizedLoginTypeName(f)); tabs[i].setUserData(f); if (factory == f) { selected = tabs[i]; 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 index d009f54cdd..f2659f122b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java @@ -14,8 +14,6 @@ /// @param the type of content node @NotNullByDefault public final class TabItem { - /// The nullable identifier of this tab. - private final StringProperty id = new SimpleStringProperty(this, "id"); /// The nullable text displayed in the header. private final StringProperty text = new SimpleStringProperty(this, "text"); /// Whether a selection model has marked this tab as selected. @@ -28,14 +26,7 @@ public final class TabItem { private @Nullable Supplier nodeSupplier; /// Creates an unselected tab with the given nullable identifier and no text or content. - public TabItem(@Nullable String id) { - setId(id); - } - - /// Creates an unselected tab with the given nullable identifier and text, and no content. - public TabItem(@Nullable String id, @Nullable String text) { - setId(id); - setText(text); + public TabItem() { } /// Returns the content factory, or null if none has been set. @@ -48,21 +39,6 @@ public void setNodeSupplier(@Nullable Supplier nodeSuppli this.nodeSupplier = nodeSupplier; } - /// Returns the tab identifier, which may be null. - public @Nullable String getId() { - return id.get(); - } - - /// Returns the writable identifier property, whose value may be null. - public StringProperty idProperty() { - return id; - } - - /// Sets the tab identifier, which may be null. - public void setId(@Nullable String id) { - this.id.set(id); - } - /// Returns the header text, which may be null. public @Nullable String getText() { return text.get(); 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 8773d55448..470b165a53 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 @@ -67,17 +67,17 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("download"), -1)); private final TabHeader tab; /// The tab for choosing a game version to install. - private final TabItem newGameTab = new TabItem<>("newGameTab"); + private final TabItem newGameTab = new TabItem<>(); /// The tab for browsing downloadable mods. - private final TabItem modTab = new TabItem<>("modTab"); + private final TabItem modTab = new TabItem<>(); /// The tab for browsing and importing modpacks. - private final TabItem modpackTab = new TabItem<>("modpackTab"); + private final TabItem modpackTab = new TabItem<>(); /// The tab for browsing downloadable resource packs. - private final TabItem resourcePackTab = new TabItem<>("resourcePackTab"); + private final TabItem resourcePackTab = new TabItem<>(); /// The tab for browsing downloadable shader packs. - private final TabItem shaderTab = new TabItem<>("shaderTab"); + private final TabItem shaderTab = new TabItem<>(); /// The tab for browsing downloadable worlds. - private final TabItem worldTab = new TabItem<>("worldTab"); + private final TabItem worldTab = new TabItem<>(); private final TransitionPane transitionPane = new TransitionPane(); private final DownloadNavigator versionPageNavigator = new DownloadNavigator(); 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 9fa4ba3573..b4a6781977 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 @@ -40,19 +40,19 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); private final TabHeader tab; /// The settings tab bound to the current game instance. - private final TabItem> gameSettingsTab = new TabItem<>("versionSettingsTab"); + private final TabItem> gameSettingsTab = new TabItem<>(); /// The component management tab bound to the current game instance. - private final TabItem installerListTab = new TabItem<>("installerListTab"); + private final TabItem installerListTab = new TabItem<>(); /// The installed mods tab bound to the current game instance. - private final TabItem modListTab = new TabItem<>("modListTab"); + private final TabItem modListTab = new TabItem<>(); /// The saved worlds tab bound to the current game instance. - private final TabItem worldListTab = new TabItem<>("worldList"); + private final TabItem worldListTab = new TabItem<>(); /// The schematics tab bound to the current game instance. - private final TabItem schematicsTab = new TabItem<>("schematicsTab"); + private final TabItem schematicsTab = new TabItem<>(); /// The installed resource packs tab bound to the current game instance. - private final TabItem resourcePackTab = new TabItem<>("resourcePackTab"); + private final TabItem resourcePackTab = new TabItem<>(); /// The screenshots tab bound to the current game instance. - private final TabItem screenshotsTab = new TabItem<>("screenshotsTab"); + private final TabItem screenshotsTab = new TabItem<>(); private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty(); private final ObjectProperty instance = 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 89b34653f8..8a4fcd27a8 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 @@ -48,11 +48,11 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final TransitionPane transitionPane = new TransitionPane(); private final TabHeader header = new TabHeader(transitionPane); /// The tab displaying information about this world. - private final TabItem worldInfoTab = new TabItem<>("worldInfoPage"); + private final TabItem worldInfoTab = new TabItem<>(); /// The tab for managing backups of this world. - private final TabItem worldBackupsTab = new TabItem<>("worldBackupsPage"); + private final TabItem worldBackupsTab = new TabItem<>(); /// The tab for managing data packs in this world. - private final TabItem dataPackTab = new TabItem<>("dataPackListPage"); + private final TabItem dataPackTab = new TabItem<>(); public WorldManagePage(World world, HMCLGameInstance gameInstance) { this.world = world; 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 f9b012bda8..aac3a491cc 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 @@ -24,21 +24,21 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("settings"))); private final TabHeader tab; /// The tab for editing game settings presets. - private final TabItem> gameTab = new TabItem<>("versionSettingsPage"); + private final TabItem> gameTab = new TabItem<>(); /// The tab whose content manages installed Java runtimes. - private final TabItem javaManagementTab = new TabItem<>("javaManagementPage"); + private final TabItem javaManagementTab = new TabItem<>(); /// The tab for general launcher settings. - private final TabItem settingsTab = new TabItem<>("settingsPage"); + private final TabItem settingsTab = new TabItem<>(); /// The tab for launcher appearance settings. - private final TabItem personalizationTab = new TabItem<>("personalizationPage"); + private final TabItem personalizationTab = new TabItem<>(); /// The tab for download settings. - private final TabItem downloadTab = new TabItem<>("downloadSettingsPage"); + private final TabItem downloadTab = new TabItem<>(); /// The tab displaying launcher help. - private final TabItem helpTab = new TabItem<>("helpPage"); + private final TabItem helpTab = new TabItem<>(); /// The tab displaying launcher information. - private final TabItem aboutTab = new TabItem<>("aboutPage"); + private final TabItem aboutTab = new TabItem<>(); /// The tab displaying feedback and contact information. - private final TabItem feedbackTab = new TabItem<>("feedbackPage"); + private final TabItem feedbackTab = new TabItem<>(); private final TransitionPane transitionPane = new TransitionPane(); public LauncherSettingsPage() { 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 1f6448c894..5a2a4f097d 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 @@ -38,7 +38,7 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("terracotta.terracotta"))); private final TabHeader tab; /// The tab displaying the Terracotta controller page. - private final TabItem statusPage = new TabItem<>("statusPage"); + private final TabItem statusPage = new TabItem<>(); private final TransitionPane transitionPane = new TransitionPane(); @SuppressWarnings("unused") From 7b2bef508e8f93a343215dc2a5098d9e693b5cae Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 27 Oct 2026 22:20:45 +0800 Subject: [PATCH 07/24] Refactor DownloadPage to improve tab initialization and structure --- .../jackhuang/hmcl/ui/construct/TabItem.java | 4 + .../hmcl/ui/download/DownloadPage.java | 84 +++++++++++-------- 2 files changed, 51 insertions(+), 37 deletions(-) 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 index f2659f122b..2d40fd4ceb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java @@ -29,6 +29,10 @@ public final class TabItem { public TabItem() { } + public TabItem(Supplier nodeSupplier) { + this.nodeSupplier = nodeSupplier; + } + /// Returns the content factory, or null if none has been set. public @Nullable Supplier getNodeSupplier() { return nodeSupplier; 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 470b165a53..d788dbe4bc 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 @@ -65,19 +65,19 @@ 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 tabHeader; /// The tab for choosing a game version to install. - private final TabItem newGameTab = new TabItem<>(); - /// The tab for browsing downloadable mods. - private final TabItem modTab = new TabItem<>(); + private final TabItem newGameTab; /// The tab for browsing and importing modpacks. - private final TabItem modpackTab = new TabItem<>(); + 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 = new TabItem<>(); + private final TabItem resourcePackTab; /// The tab for browsing downloadable shader packs. - private final TabItem shaderTab = new TabItem<>(); + private final TabItem shaderTab; /// The tab for browsing downloadable worlds. - private final TabItem worldTab = new TabItem<>(); + private final TabItem worldTab; private final TransitionPane transitionPane = new TransitionPane(); private final DownloadNavigator versionPageNavigator = new DownloadNavigator(); @@ -88,9 +88,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); @@ -100,40 +104,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.tabHeader = new TabHeader(transitionPane, + newGameTab, + modpackTab, + modTab, + resourcePackTab, + shaderTab, + worldTab + ); GameDirectoryManager.registerVersionsListener(this::loadVersions); - tab.select(newGameTab); + tabHeader.select(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(tabHeader, newGameTab, i18n("game"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL) + .addNavigationDrawerTab(tabHeader, 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(tabHeader, modTab, i18n("mods"), SVG.EXTENSION, SVG.EXTENSION_FILL) + .addNavigationDrawerTab(tabHeader, resourcePackTab, i18n("resourcepack"), SVG.TEXTURE) + .addNavigationDrawerTab(tabHeader, shaderTab, i18n("download.shader"), SVG.WB_SUNNY, SVG.WB_SUNNY_FILL) + .addNavigationDrawerTab(tabHeader, 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) { @@ -204,25 +214,25 @@ public ReadOnlyObjectProperty stateProperty() { } public void showGameDownloads() { - tab.select(newGameTab, false); + tabHeader.select(newGameTab, false); } public void showModpackDownloads() { - tab.select(modpackTab, false); + tabHeader.select(modpackTab, false); } public DownloadListPage showResourcePackDownloads() { - tab.select(resourcePackTab, false); + tabHeader.select(resourcePackTab, false); return resourcePackTab.getNode(); } public DownloadListPage showModDownloads() { - tab.select(modTab, false); + tabHeader.select(modTab, false); return modTab.getNode(); } public void showWorldDownloads() { - tab.select(worldTab, false); + tabHeader.select(worldTab, false); } private static final class DownloadNavigator implements Navigation { From 570e520ca3e0b53cde49a9baed97f76899cda57e Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 27 Oct 2026 22:24:12 +0800 Subject: [PATCH 08/24] Refactor GameInstancePage and WorldManagePage to initialize TabItems directly --- .../hmcl/ui/instances/GameInstancePage.java | 39 +++++++++++-------- .../hmcl/ui/instances/WorldManagePage.java | 12 +++--- 2 files changed, 29 insertions(+), 22 deletions(-) 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 b4a6781977..3d84fcba42 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 @@ -40,19 +40,19 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); private final TabHeader tab; /// The settings tab bound to the current game instance. - private final TabItem> gameSettingsTab = new TabItem<>(); + private final TabItem> gameSettingsTab; /// The component management tab bound to the current game instance. - private final TabItem installerListTab = new TabItem<>(); + private final TabItem installerListTab; /// The installed mods tab bound to the current game instance. - private final TabItem modListTab = new TabItem<>(); + private final TabItem modListTab; /// The saved worlds tab bound to the current game instance. - private final TabItem worldListTab = new TabItem<>(); + private final TabItem worldListTab; /// The schematics tab bound to the current game instance. - private final TabItem schematicsTab = new TabItem<>(); + private final TabItem schematicsTab; /// The installed resource packs tab bound to the current game instance. - private final TabItem resourcePackTab = new TabItem<>(); + private final TabItem resourcePackTab; /// The screenshots tab bound to the current game instance. - private final TabItem screenshotsTab = new TabItem<>(); + private final TabItem screenshotsTab; private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty(); private final ObjectProperty instance = @@ -79,15 +79,22 @@ 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); + 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)); + + tab = new TabHeader(transitionPane, + gameSettingsTab, + installerListTab, + modListTab, + resourcePackTab, + worldListTab, + schematicsTab, + screenshotsTab); tab.select(gameSettingsTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); 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 8a4fcd27a8..300c508901 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 @@ -48,11 +48,11 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final TransitionPane transitionPane = new TransitionPane(); private final TabHeader header = new TabHeader(transitionPane); /// The tab displaying information about this world. - private final TabItem worldInfoTab = new TabItem<>(); + private final TabItem worldInfoTab; /// The tab for managing backups of this world. - private final TabItem worldBackupsTab = new TabItem<>(); + private final TabItem worldBackupsTab; /// The tab for managing data packs in this world. - private final TabItem dataPackTab = new TabItem<>(); + private final TabItem dataPackTab; public WorldManagePage(World world, HMCLGameInstance gameInstance) { this.world = world; @@ -68,9 +68,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)); From cdb63e59f4e381e43c53a46192dc4b35412d8beb Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 00:57:02 +0800 Subject: [PATCH 09/24] Refactor TabHeader to simplify onPageShown and onPageHidden methods --- .../org/jackhuang/hmcl/ui/construct/TabHeader.java | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) 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 661ba340e6..e726496c45 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 @@ -111,10 +111,8 @@ public void select(TabItem tab, boolean playAnimation) { @Override public void onPageShown() { @Nullable TabItem tab = getSelectionModel().getSelectedItem(); - if (tab != null) { - if (tab.getNode() instanceof PageAware) { - ((PageAware) tab.getNode()).onPageShown(); - } + if (tab != null && tab.getNode() instanceof PageAware pageAware) { + pageAware.onPageShown(); } } @@ -122,10 +120,8 @@ public void onPageShown() { @Override public void onPageHidden() { @Nullable TabItem tab = getSelectionModel().getSelectedItem(); - if (tab != null) { - if (tab.getNode() instanceof PageAware) { - ((PageAware) tab.getNode()).onPageHidden(); - } + if (tab != null && tab.getNode() instanceof PageAware pageAware) { + pageAware.onPageHidden(); } } From 0071329fe7ec63800a6706aa9a3b6eb87cd860ff Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 00:58:49 +0800 Subject: [PATCH 10/24] Refactor TabHeader to replace TabHeaderContainer with TabHeaderView for improved clarity and consistency --- .../hmcl/ui/construct/TabHeader.java | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) 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 e726496c45..d962b7c4b2 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 @@ -309,11 +309,11 @@ public static class TabHeaderSkin extends SkinBase { /// 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; + private final @UnmodifiableView ObservableList headers; /// Releases removed views and updates the layout children after list changes. - private final ListChangeListener headersListener = change -> { + private final ListChangeListener headersListener = change -> { while (change.next()) - change.getRemoved().forEach(TabHeaderContainer::dispose); + change.getRemoved().forEach(TabHeaderView::dispose); updateChildren(); }; /// The animated distance from the leading inset along the row or column. @@ -342,7 +342,7 @@ protected TabHeaderSkin(TabHeader control) { lineOffset.addListener(observable -> control.requestLayout()); lineLength.addListener(observable -> control.requestLayout()); - headers = MappedObservableList.create(getSkinnable().getTabs(), TabHeaderContainer::new); + headers = MappedObservableList.create(getSkinnable().getTabs(), TabHeaderView::new); headers.addListener(headersListener); updateChildren(); @@ -365,7 +365,7 @@ protected TabHeaderSkin(TabHeader control) { public void dispose() { stopAnimation(); headers.removeListener(headersListener); - headers.forEach(TabHeaderContainer::dispose); + headers.forEach(TabHeaderView::dispose); getChildren().clear(); super.dispose(); } @@ -381,7 +381,7 @@ private void updateChildren() { @Override protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { double width = 0; - for (TabHeaderContainer child : headers) { + for (TabHeaderView child : headers) { if (!child.isManaged()) continue; width = Math.max(width, snapSizeX(child.minWidth(-1))); } @@ -392,7 +392,7 @@ protected double computeMinWidth(double height, double topInset, double rightIns @Override protected double computeMinHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { double height = 0; - for (TabHeaderContainer child : headers) { + for (TabHeaderView child : headers) { if (!child.isManaged()) continue; height = Math.max(height, snapSizeY(child.minHeight(-1))); } @@ -404,7 +404,7 @@ protected double computeMinHeight(double width, double topInset, double rightIns protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { boolean horizontal = getSkinnable().getSide().isHorizontal(); double width = 0; - for (TabHeaderContainer child : headers) { + for (TabHeaderView child : headers) { if (!child.isVisible()) continue; double childWidth = snapSizeX(child.prefWidth(height)); width = horizontal ? width + childWidth : Math.max(width, childWidth); @@ -417,7 +417,7 @@ protected double computePrefWidth(double height, double topInset, double rightIn protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { boolean horizontal = getSkinnable().getSide().isHorizontal(); double height = 0; - for (TabHeaderContainer child : headers) { + for (TabHeaderView child : headers) { if (!child.isVisible()) continue; double childHeight = snapSizeY(child.prefHeight(width)); height = horizontal ? Math.max(height, childHeight) : height + childHeight; @@ -434,7 +434,7 @@ protected void layoutChildren(double contentX, double contentY, double contentWi double selectedOffset = 0; double selectedLength = 0; @Nullable TabItem selected = getSkinnable().getSelectionModel().getSelectedItem(); - for (TabHeaderContainer child : headers) { + for (TabHeaderView child : headers) { if (!child.isVisible()) continue; double width = snapSizeX(child.prefWidth(-1)); double height = snapSizeY(child.prefHeight(-1)); @@ -507,7 +507,7 @@ private void stopAnimation() { /// Displays one tab's label, selection styling, and pointer feedback. @NotNullByDefault - private final class TabHeaderContainer extends StackPane { + private final class TabHeaderView extends StackPane { /// The tab represented by this view. private final TabItem tab; /// The label bound to the tab's text. @@ -518,7 +518,7 @@ private final class TabHeaderContainer extends StackPane { private final InvalidationListener selectionListener = observable -> updateSelection(); /// Binds the label and selection styling and installs pointer selection. - private TabHeaderContainer(TabItem tab) { + private TabHeaderView(TabItem tab) { this.tab = tab; tabText.textProperty().bind(tab.textProperty()); tabText.getStyleClass().add("tab-label"); From 477d862c6c6a4f50c37b2dbf0c4aff14e00073c3 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:06:09 +0800 Subject: [PATCH 11/24] Refactor TabHeaderView to enhance tab representation and simplify selection handling --- .../hmcl/ui/construct/TabHeader.java | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) 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 d962b7c4b2..d4a35f759d 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 @@ -12,10 +12,8 @@ import javafx.geometry.Side; import javafx.scene.AccessibleAttribute; import javafx.scene.control.*; -import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; 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; @@ -507,33 +505,30 @@ private void stopAnimation() { /// Displays one tab's label, selection styling, and pointer feedback. @NotNullByDefault - private final class TabHeaderView extends StackPane { + 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 = new Label(); - /// The content node receiving the selection pseudo-class. - private final BorderPane inner = new BorderPane(); + private final Label tabText; /// Updates selection styling until this view is removed or the skin is disposed. private final InvalidationListener selectionListener = observable -> updateSelection(); - /// Binds the label and selection styling and installs pointer selection. + /// 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; + this.tabText = (Label) getContainer(); tabText.textProperty().bind(tab.textProperty()); tabText.getStyleClass().add("tab-label"); - inner.setCenter(tabText); - inner.getStyleClass().add("tab-container"); - inner.setMouseTransparent(true); - getChildren().setAll(new RipplerContainer(inner)); + tabText.setMouseTransparent(true); tab.selectedProperty().addListener(selectionListener); updateSelection(); - FXUtils.onClicked(this, () -> getSkinnable().getSelectionModel().select(tab)); + setOnAction(event -> getSkinnable().getSelectionModel().select(tab)); } - /// Reflects the tab's selection flag in the content pseudo-class. + /// Reflects the tab's selection flag in the label's pseudo-class. private void updateSelection() { - inner.pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, tab.isSelected()); + tabText.pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, tab.isSelected()); } /// Releases the tab's text binding and selection listener. From c1ce8795942e726d13829a1123d0bddd9f5c30f2 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:11:20 +0800 Subject: [PATCH 12/24] Refactor TabHeader to initialize side property lazily and improve property access --- .../java/org/jackhuang/hmcl/ui/construct/TabHeader.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 d4a35f759d..50b68c5868 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 @@ -123,12 +123,14 @@ public void onPageHidden() { } } - private final ObjectProperty side = new SimpleObjectProperty<>(Side.TOP); + private @Nullable ObjectProperty side; /** * The position of the tabs. */ public ObjectProperty sideProperty() { + if (side == null) + side = new SimpleObjectProperty<>(this, "side", Side.TOP); return side; } @@ -136,14 +138,14 @@ public ObjectProperty sideProperty() { * The position to place the tabs. */ public Side getSide() { - return side.get(); + return sideProperty().get(); } /** * The position the place the tabs in this TabHeader. */ public void setSide(Side side) { - this.side.set(side); + this.sideProperty().set(side); } @Override From 018c1c6f9c900aa801fe11df2f61a4fef5bf3235 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:11:47 +0800 Subject: [PATCH 13/24] Refactor TabHeader to simplify getItemCount method by removing unnecessary variable --- .../main/java/org/jackhuang/hmcl/ui/construct/TabHeader.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 50b68c5868..a5ab482be8 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 @@ -251,8 +251,7 @@ public void select(@Nullable TabItem tab) { /// Returns the current number of tabs in the header. @Override protected int getItemCount() { - final ObservableList> items = getTabs(); - return items.size(); + return getTabs().size(); } /// Select the tab near a removed position, searching left before right at each distance. From 0059efbd0d8d05d3d1068f1a06d75bafe565348c Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:24:06 +0800 Subject: [PATCH 14/24] Separate tab presentation from selection and content control Assisted-by: codex:gpt-6-astra --- .../hmcl/ui/construct/AdvancedListBox.java | 16 +- .../hmcl/ui/construct/TabController.java | 288 ++++++++++++++++++ .../hmcl/ui/construct/TabHeader.java | 286 +++-------------- .../hmcl/ui/download/DownloadPage.java | 31 +- .../hmcl/ui/instances/GameInstancePage.java | 23 +- .../hmcl/ui/instances/WorldManagePage.java | 17 +- .../hmcl/ui/main/LauncherSettingsPage.java | 31 +- .../hmcl/ui/terracotta/TerracottaPage.java | 13 +- 8 files changed, 393 insertions(+), 312 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java 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 aa8ece75ef..0f432d7510 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 @@ -78,25 +78,25 @@ public AdvancedListBox addNavigationDrawerItem(String title, SVG leftGraphic, Ru return add(item); } - /// Adds an item that selects the tab and tracks its selection state. + /// 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(TabHeader tabHeader, TabItem tab, String title, @Nullable SVG leftGraphic) { + 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.getSelectionModel().selectedItemProperty().isEqualTo(tab)); + item.setOnAction(e -> controller.select(tab)); return add(item); } - /// Adds an item that selects the tab and switches icons with its selection state. + /// Adds an item that activates the tab through the controller and switches icons with its selection state. /// /// @return this list box - public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabItem tab, String title, + 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.getSelectionModel().selectedItemProperty().isEqualTo(tab)); + item.setOnAction(e -> controller.select(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/TabController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java new file mode 100644 index 0000000000..5223c01495 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java @@ -0,0 +1,288 @@ +// 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.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.scene.control.SingleSelectionModel; +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; + +/// Manages tab selection, lazy content initialization, and page lifecycle callbacks. +/// An optional transition pane displays content activated by [#select(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; + + /// 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 @Nullable ... tabs) { + this(null, tabs); + } + + /// Creates a controller with an optional destination for selected content. + /// + /// @param contentPane the destination updated by [#select(TabItem)], or null + /// @param tabs the initial tabs in display order, or null for an empty controller + public TabController(@Nullable TransitionPane contentPane, TabItem @Nullable ... tabs) { + this.contentPane = contentPane; + + if (tabs != null) { + getTabs().addAll(tabs); + } + } + + /// The mutable tabs in display order. + private final ObservableList> tabs = FXCollections.observableArrayList(); + + /// Returns the mutable, observable list of tabs in display order. + /// Entries must be non-null and distinct, and must not belong to another controller. + /// List changes update selection state without initializing or switching content. + public ObservableList> getTabs() { + return tabs; + } + + /// The selection model that tracks changes to the tab list. + private final SingleSelectionModel<@Nullable TabItem> selectionModel = new TabSelectionModel(); + + /// Returns the selection model without activating content or invoking page callbacks. + /// Direct selection changes, clearing, and list-driven selection only update state. + /// Selecting a non-null item outside the list sets its selected index to -1; + /// selecting a null item or an out-of-range index leaves selection unchanged. + /// Use [#select(TabItem)] to initialize and display the selected content. + public SingleSelectionModel<@Nullable TabItem> getSelectionModel() { + return selectionModel; + } + + /// Selects the tab and switches its content with animation when content is already displayed. + /// + /// @see #select(TabItem, boolean) + public void select(TabItem tab) { + select(tab, true); + } + + /// Hides the previous page, initializes and shows the requested page, updates the + /// selection, and replaces the content of the configured transition pane. + /// Page callbacks are invoked even when the requested tab is already selected. + /// Callbacks apply to nodes implementing [PageAware]. When a content destination + /// is configured, the tab must provide a non-null node after initialization. + /// Without a destination, a tab may have no content. + /// Exceptions from factories, callbacks, or content replacement propagate; + /// completed callbacks, initialization, and selection changes are not rolled back. + /// + /// @param tab the tab to select + /// @param playAnimation whether to animate replacement of existing content + public void select(TabItem tab, boolean playAnimation) { + @Nullable TabItem 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); + } + } + } + + /// Forwards the shown callback to the selected content when it implements [PageAware]. + @Override + public void onPageShown() { + @Nullable TabItem tab = getSelectionModel().getSelectedItem(); + if (tab != null && tab.getNode() instanceof PageAware pageAware) { + pageAware.onPageShown(); + } + } + + /// Forwards the hidden callback to the selected content when it implements [PageAware]. + @Override + public void onPageHidden() { + @Nullable TabItem tab = getSelectionModel().getSelectedItem(); + if (tab != null && tab.getNode() instanceof PageAware pageAware) { + pageAware.onPageHidden(); + } + } + + /// Synchronizes the selected index, selected item, and tab selection flags. + /// + /// Changes to the tab list update the selection. Selecting through this model + /// does not initialize content or invoke page lifecycle callbacks. + @NotNullByDefault + private final class TabSelectionModel extends SingleSelectionModel<@Nullable TabItem> { + /// Registers a listener that keeps the selection aligned with this controller's tab list. + private TabSelectionModel() { + // watching for changes to the items list content + final ListChangeListener> itemsContentObserver = c -> { + while (c.next()) { + for (@Nullable TabItem tab : c.getRemoved()) { + if (tab != null && !getTabs().contains(tab)) { + if (tab.isSelected()) { + tab.setSelected(false); + final int tabIndex = c.getFrom(); + + // Select a nearby tab after removing the selected tab. + selectNearestAvailableTab(tabIndex); + } + } + } + if (c.wasAdded() || c.wasRemoved() || c.wasPermutated()) { + // 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() != getTabs().indexOf(getSelectedItem())) { + clearAndSelect(getTabs().indexOf(getSelectedItem())); + } + } + } + if (getSelectedIndex() == -1 && getSelectedItem() == null && !getTabs().isEmpty()) { + // Select the first available tab when the list gains its first items. + selectNearestAvailableTab(0); + } else if (getTabs().isEmpty()) { + clearSelection(); + } + }; + getTabs().addListener(itemsContentObserver); + } + + /// Selects the tab at the given index; ignores indices outside the tab list. + /// Updates the selection flags of the old and new tabs. + @Override + public void select(int index) { + if (index < 0 || index >= getItemCount() || + (index == getSelectedIndex() && getModelItem(index).isSelected())) { + return; + } + + // Use the item rather than its former index, which may have shifted. + @Nullable TabItem oldTab = getSelectedItem(); + if (oldTab != null) + oldTab.setSelected(false); + + setSelectedIndex(index); + + @Nullable TabItem tab = getModelItem(index); + if (tab != null) { + setSelectedItem(tab); + } + + // Select the new tab + if (getSelectedIndex() >= 0 && getSelectedIndex() < getTabs().size()) { + getTabs().get(getSelectedIndex()).setSelected(true); + } + } + + /// Clears the selected item, index, and tab selection flag without switching content. + @Override + public void clearSelection() { + @Nullable TabItem oldTab = getSelectedItem(); + if (oldTab != null) + oldTab.setSelected(false); + setSelectedIndex(-1); + setSelectedItem(null); + } + + /// Selects a matching tab in the list. If a non-null tab is absent, updates + /// the selected item with index -1; a null argument leaves the selection unchanged. + @Override + public void select(@Nullable TabItem tab) { + final int itemCount = getItemCount(); + + for (int i = 0; i < itemCount; i++) { + final @Nullable TabItem value = getModelItem(i); + if (value != null && value.equals(tab)) { + select(i); + return; + } + } + if (tab != null) { + @Nullable TabItem oldTab = getSelectedItem(); + if (oldTab != null) + oldTab.setSelected(false); + setSelectedIndex(-1); + tab.setSelected(true); + setSelectedItem(tab); + } + } + + /// Returns the tab at the given index, or null if the index is out of range. + @Override + protected @Nullable TabItem getModelItem(int index) { + final ObservableList> items = getTabs(); + if (index < 0 || index >= items.size()) return null; + return items.get(index); + } + + /// Returns the current number of tabs in the controller. + @Override + protected int getItemCount() { + return getTabs().size(); + } + + /// Select the tab near a removed position, searching left before right at each distance. + /// + /// @param tabIndex the position from which a tab was removed + private void selectNearestAvailableTab(int tabIndex) { + // Search outwards from the removed position, preferring the left side. + final int tabCount = getItemCount(); + int i = 1; + @Nullable TabItem bestTab = null; + while (true) { + // look leftwards + int downPos = tabIndex - i; + if (downPos >= 0) { + @Nullable TabItem candidate = getModelItem(downPos); + if (candidate != null) { + bestTab = candidate; + 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) { + @Nullable TabItem candidate = getModelItem(upPos); + if (candidate != null) { + bestTab = candidate; + break; + } + } + + if (downPos < 0 && upPos >= tabCount) { + break; + } + i++; + } + + if (bestTab != null) { + select(bestTab); + } + } + } +} 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 a5ab482be8..14457798e3 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 @@ -5,7 +5,6 @@ import javafx.animation.*; import javafx.beans.InvalidationListener; import javafx.beans.property.*; -import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.css.PseudoClass; @@ -14,291 +13,85 @@ import javafx.scene.control.*; import javafx.scene.layout.StackPane; import javafx.util.Duration; -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; -/// Displays selectable tab headers and optionally switches content in a transition pane. -public class TabHeader extends Control implements PageAware { +import java.util.Objects; - /// The optional destination for selected tab content. - private final @Nullable TransitionPane contentPane; +/// 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 { - /// Creates a header with the supplied tabs and no content destination. + /// 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 @Nullable ... tabs) { - this(null, tabs); + this(new TabController(tabs)); } - /// Creates a header with an optional destination for selected content. + /// Creates a view of the supplied controller's tabs and selection state. + /// Creating or disposing a skin does not initialize or activate tab content. /// - /// @param contentPane the destination updated by [#select(TabItem)], or null - /// @param tabs the initial tabs in display order, or null for an empty header - public TabHeader(@Nullable TransitionPane contentPane, TabItem @Nullable ... tabs) { - this.contentPane = contentPane; - + /// @param controller the controller shared with other navigation views + /// @throws NullPointerException if the controller is null + public TabHeader(TabController controller) { + this.controller = Objects.requireNonNull(controller); getStyleClass().setAll("tab-header"); - if (tabs != null) { - getTabs().addAll(tabs); - } } - /// The mutable tabs in display order. - private final ObservableList> tabs = FXCollections.observableArrayList(); - - /// Returns the mutable, observable list of tabs in display order. - public ObservableList> getTabs() { - return tabs; + /// Returns the controller used to activate tabs and manage content lifecycle. + public final TabController getController() { + return controller; } - /// The selection model that tracks changes to the tab list. - private final SingleSelectionModel<@Nullable TabItem> selectionModel = new TabHeaderSelectionModel(); - - /// Returns the selection model; selecting through it does not switch content. - public SingleSelectionModel<@Nullable TabItem> getSelectionModel() { - return selectionModel; - } - - /// Selects the tab and switches its content with animation when content is already displayed. + /// Returns the controller's mutable tab list in display order. /// - /// @see #select(TabItem, boolean) - public void select(TabItem tab) { - select(tab, true); + /// @see TabController#getTabs() + public final ObservableList> getTabs() { + return controller.getTabs(); } - /// Hides the previous page, initializes and shows the requested page, updates the - /// selection, and replaces the content of the configured transition pane. - /// Page callbacks are invoked even when the requested tab is already selected. + /// Returns the controller's selection model; direct changes do not activate content. /// - /// @param tab the tab to select - /// @param playAnimation whether to animate replacement of existing content - public void select(TabItem tab, boolean playAnimation) { - @Nullable TabItem 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); - } - } - } - - /// Forwards the shown callback to the selected content when it implements [PageAware]. - @Override - public void onPageShown() { - @Nullable TabItem tab = getSelectionModel().getSelectedItem(); - if (tab != null && tab.getNode() instanceof PageAware pageAware) { - pageAware.onPageShown(); - } - } - - /// Forwards the hidden callback to the selected content when it implements [PageAware]. - @Override - public void onPageHidden() { - @Nullable TabItem tab = getSelectionModel().getSelectedItem(); - if (tab != null && tab.getNode() instanceof PageAware pageAware) { - pageAware.onPageHidden(); - } + /// @see TabController#getSelectionModel() + public final SingleSelectionModel<@Nullable TabItem> getSelectionModel() { + return controller.getSelectionModel(); } + /// 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 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.sideProperty().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); } - /// Synchronizes a header's selected index, selected item, and tab selection flags. - /// - /// Changes to the tab list update the selection. Selecting through this model - /// does not initialize content or invoke page lifecycle callbacks. - @NotNullByDefault - private final class TabHeaderSelectionModel extends SingleSelectionModel<@Nullable TabItem> { - /// Registers a listener that keeps the selection aligned with this header's tab list. - private TabHeaderSelectionModel() { - // watching for changes to the items list content - final ListChangeListener> itemsContentObserver = c -> { - while (c.next()) { - for (@Nullable TabItem tab : c.getRemoved()) { - if (tab != null && !getTabs().contains(tab)) { - if (tab.isSelected()) { - tab.setSelected(false); - final int tabIndex = c.getFrom(); - - // Select a nearby tab after removing the selected tab. - selectNearestAvailableTab(tabIndex); - } - } - } - 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() != getTabs().indexOf(getSelectedItem())) { - clearAndSelect(getTabs().indexOf(getSelectedItem())); - } - } - } - if (getSelectedIndex() == -1 && getSelectedItem() == null && !getTabs().isEmpty()) { - // Select the first available tab when the list gains its first items. - selectNearestAvailableTab(0); - } else if (getTabs().isEmpty()) { - clearSelection(); - } - }; - getTabs().addListener(itemsContentObserver); - } - - /// Selects the tab at the given index and notifies accessibility listeners. - /// Updates the selection flags of the old and new tabs. - @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() < getTabs().size()) { - getTabs().get(getSelectedIndex()).setSelected(false); - } - - setSelectedIndex(index); - - @Nullable TabItem tab = getModelItem(index); - if (tab != null) { - setSelectedItem(tab); - } - - // Select the new tab - if (getSelectedIndex() >= 0 && getSelectedIndex() < getTabs().size()) { - getTabs().get(getSelectedIndex()).setSelected(true); - } - - notifyAccessibleAttributeChanged(AccessibleAttribute.FOCUS_ITEM); - } - - /// Selects a matching tab in the list. If a non-null tab is absent, updates - /// only the selected item; a null argument leaves the selection unchanged. - @Override - public void select(@Nullable TabItem tab) { - final int itemCount = getItemCount(); - - for (int i = 0; i < itemCount; i++) { - final @Nullable TabItem value = getModelItem(i); - if (value != null && value.equals(tab)) { - select(i); - return; - } - } - if (tab != null) { - setSelectedItem(tab); - } - } - - /// Returns the tab at the given index, or null if the index is out of range. - @Override - protected @Nullable TabItem getModelItem(int index) { - final ObservableList> items = getTabs(); - if (index < 0 || index >= items.size()) return null; - return items.get(index); - } - - /// Returns the current number of tabs in the header. - @Override - protected int getItemCount() { - return getTabs().size(); - } - - /// Select the tab near a removed position, searching left before right at each distance. - /// - /// @param tabIndex the position from which a tab was removed - private void selectNearestAvailableTab(int tabIndex) { - // Search outwards from the removed position, preferring the left side. - final int tabCount = getItemCount(); - int i = 1; - @Nullable TabItem bestTab = null; - while (true) { - // look leftwards - int downPos = tabIndex - i; - if (downPos >= 0) { - @Nullable TabItem candidate = getModelItem(downPos); - if (candidate != null) { - bestTab = candidate; - 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) { - @Nullable TabItem candidate = getModelItem(upPos); - if (candidate != null) { - bestTab = candidate; - break; - } - } - - if (downPos < 0 && upPos >= tabCount) { - break; - } - i++; - } - - if (bestTab != null) { - select(bestTab); - } - } - } - /// Arranges tab headers and animates the selection indicator along the chosen side. @NotNullByDefault public static class TabHeaderSkin extends SkinBase { @@ -346,17 +139,12 @@ protected TabHeaderSkin(TabHeader control) { updateChildren(); registerChangeListener(control.getSelectionModel().selectedItemProperty(), observable -> { + control.notifyAccessibleAttributeChanged(AccessibleAttribute.FOCUS_ITEM); animateSelection = true; control.requestLayout(); }); registerChangeListener(control.sideProperty(), observable -> control.requestLayout()); - if (control.getSelectionModel().getSelectedItem() == null) { - if (control.getSelectionModel().getSelectedIndex() != -1) - control.getSelectionModel().select(control.getSelectionModel().getSelectedIndex()); - if (control.getSelectionModel().getSelectedItem() == null) - control.getSelectionModel().selectFirst(); - } } /// Stops the indicator animation and releases tab bindings and listeners. @@ -524,7 +312,7 @@ private TabHeaderView(TabItem tab) { tabText.setMouseTransparent(true); tab.selectedProperty().addListener(selectionListener); updateSelection(); - setOnAction(event -> getSkinnable().getSelectionModel().select(tab)); + setOnAction(event -> getSkinnable().getController().select(tab)); } /// Reflects the tab's selection flag in the label's pseudo-class. 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 d788dbe4bc..d2dffe8360 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,7 @@ 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; @@ -65,7 +65,8 @@ 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 tabHeader; + /// 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. @@ -110,7 +111,7 @@ public DownloadPage(GameInstanceID uploadInstance) { this.shaderTab = newDownloadListTab(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true)); this.worldTab = newDownloadListTab(() -> new DownloadListPage(RemoteAddon.Type.WORLD, CurseForgeRemoteAddonRepository.getInstance())); - this.tabHeader = new TabHeader(transitionPane, + this.tabController = new TabController(transitionPane, newGameTab, modpackTab, modTab, @@ -121,17 +122,17 @@ public DownloadPage(GameInstanceID uploadInstance) { GameDirectoryManager.registerVersionsListener(this::loadVersions); - tabHeader.select(newGameTab); + tabController.select(newGameTab); AdvancedListBox sideBar = new AdvancedListBox() .startCategory(i18n("download.game").toUpperCase(Locale.ROOT)) - .addNavigationDrawerTab(tabHeader, newGameTab, i18n("game"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL) - .addNavigationDrawerTab(tabHeader, 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(tabHeader, modTab, i18n("mods"), SVG.EXTENSION, SVG.EXTENSION_FILL) - .addNavigationDrawerTab(tabHeader, resourcePackTab, i18n("resourcepack"), SVG.TEXTURE) - .addNavigationDrawerTab(tabHeader, shaderTab, i18n("download.shader"), SVG.WB_SUNNY, SVG.WB_SUNNY_FILL) - .addNavigationDrawerTab(tabHeader, 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); @@ -214,25 +215,25 @@ public ReadOnlyObjectProperty stateProperty() { } public void showGameDownloads() { - tabHeader.select(newGameTab, false); + tabController.select(newGameTab, false); } public void showModpackDownloads() { - tabHeader.select(modpackTab, false); + tabController.select(modpackTab, false); } public DownloadListPage showResourcePackDownloads() { - tabHeader.select(resourcePackTab, false); + tabController.select(resourcePackTab, false); return resourcePackTab.getNode(); } public DownloadListPage showModDownloads() { - tabHeader.select(modTab, false); + tabController.select(modTab, false); return modTab.getNode(); } public void showWorldDownloads() { - tabHeader.select(worldTab, false); + tabController.select(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 3d84fcba42..765af47178 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,7 +38,8 @@ public class GameInstancePage extends DecoratorAnimatedPage implements DecoratorPage { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); - private final TabHeader tab; + /// 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. @@ -87,7 +88,7 @@ public GameInstancePage() { this.schematicsTab = new TabItem<>(() -> new SchematicsPage(instance)); this.screenshotsTab = new TabItem<>(() -> new ScreenshotsPage(instance)); - tab = new TabHeader(transitionPane, + tabController = new TabController(transitionPane, gameSettingsTab, installerListTab, modListTab, @@ -95,7 +96,7 @@ public GameInstancePage() { worldListTab, schematicsTab, screenshotsTab); - tab.select(gameSettingsTab); + tabController.select(gameSettingsTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -166,7 +167,7 @@ private void checkSelectedInstance() { } public void showInstanceSettings() { - tab.select(gameSettingsTab, false); + tabController.select(gameSettingsTab, false); } public void setInstance(GameInstanceID instanceId, HMCLGameRepository repository) { @@ -352,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(); @@ -390,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.select(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 300c508901..8fc79176bd 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,7 +46,8 @@ 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); + /// 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. @@ -96,7 +97,7 @@ public void refresh() { return; } - for (var tab : header.getTabs()) { + for (var tab : tabController.getTabs()) { if (tab.getNode() instanceof WorldRefreshable r) { r.refresh(); } @@ -195,15 +196,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.select(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 aac3a491cc..e69669c289 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,7 +22,8 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("settings"))); - private final TabHeader tab; + /// 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 = new TabItem<>(); /// The tab whose content manages installed Java runtimes. @@ -50,22 +51,22 @@ public LauncherSettingsPage() { helpTab.setNodeSupplier(HelpPage::new); feedbackTab.setNodeSupplier(FeedbackPage::new); aboutTab.setNodeSupplier(AboutPage::new); - tab = new TabHeader(transitionPane, gameTab, javaManagementTab, settingsTab, personalizationTab, downloadTab, helpTab, feedbackTab, aboutTab); + tabController = new TabController(transitionPane, gameTab, javaManagementTab, settingsTab, personalizationTab, downloadTab, helpTab, feedbackTab, aboutTab); - tab.select(gameTab); + tabController.select(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); @@ -74,21 +75,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.select(gameTab, false); } public void showFeedback() { - tab.select(feedbackTab, false); + tabController.select(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 5a2a4f097d..0b94a7dd41 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,7 +36,8 @@ 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; + /// 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 = new TabItem<>(); private final TransitionPane transitionPane = new TransitionPane(); @@ -46,8 +47,8 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa public TerracottaPage() { statusPage.setNodeSupplier(TerracottaControllerPage::new); - tab = new TabHeader(transitionPane, statusPage); - tab.select(statusPage); + tabController = new TabController(transitionPane, statusPage); + tabController.select(statusPage); BorderPane left = new BorderPane(); FXUtils.setLimitWidth(left, 200); @@ -55,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(); @@ -99,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, () -> { @@ -111,7 +112,7 @@ public void onPageShown() { @Override public void onPageHidden() { - tab.onPageHidden(); + tabController.onPageHidden(); } @Override From f42cd33ca3cdf61cf695dabff84b64e8b899ea66 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:26:50 +0800 Subject: [PATCH 15/24] Refactor CreateAccountPane to use IdentityHashMap for tab-to-factory mapping and remove userData from TabItem --- .../hmcl/ui/account/CreateAccountPane.java | 14 +++++++++----- .../jackhuang/hmcl/ui/construct/TabItem.java | 17 ----------------- 2 files changed, 9 insertions(+), 22 deletions(-) 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 1c88f49f0c..975a581509 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 @@ -134,14 +134,18 @@ public CreateAccountPane(AccountFactory factory) { if (showMethodSwitcher) { 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 TabItem<>(); - tabs[i].setText(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; } } @@ -154,7 +158,7 @@ public CreateAccountPane(AccountFactory factory) { 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/construct/TabItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java index 2d40fd4ceb..549925fe97 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java @@ -20,8 +20,6 @@ public final class TabItem { private final ReadOnlyBooleanWrapper selected = new ReadOnlyBooleanWrapper(this, "selected"); /// The content node, or null when no content is assigned. private final ObjectProperty<@Nullable T> node = new SimpleObjectProperty<>(this, "node"); - /// Optional application data associated with this tab. - private final ObjectProperty<@Nullable Object> userData = new SimpleObjectProperty<>(this, "userData"); /// The optional factory used when content has not been assigned. private @Nullable Supplier nodeSupplier; @@ -88,21 +86,6 @@ public void setNode(@Nullable T node) { this.node.set(node); } - /// Returns the application data, or null if none is assigned. - public @Nullable Object getUserData() { - return userData.get(); - } - - /// Returns the property holding nullable application data. - public ObjectProperty<@Nullable ?> userDataProperty() { - return userData; - } - - /// Sets the application data, which may be null. - public void setUserData(@Nullable Object userData) { - this.userData.set(userData); - } - /// Returns whether a non-null content node is currently assigned. public boolean isInitialized() { return getNode() != null; From d1fbd71ce6a42815d9f48fbab9ac4298397e1969 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:35:19 +0800 Subject: [PATCH 16/24] Centralize tab selection in TabController and remove per-tab selection state Assisted-by: codex:gpt-6-astra --- .../hmcl/ui/account/CreateAccountPane.java | 5 +- .../hmcl/ui/animation/TransitionPane.java | 16 +- .../hmcl/ui/construct/AdvancedListBox.java | 4 +- .../hmcl/ui/construct/TabController.java | 309 ++++++------------ .../hmcl/ui/construct/TabHeader.java | 24 +- .../jackhuang/hmcl/ui/construct/TabItem.java | 22 +- 6 files changed, 127 insertions(+), 253 deletions(-) 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 975a581509..02a3be2393 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 @@ -153,8 +153,9 @@ 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().select(selected); + onChange(tabHeader.getController().selectedTabProperty(), newItem -> { if (newItem == null) return; 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 0f432d7510..4cbd7ce086 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 @@ -84,7 +84,7 @@ public AdvancedListBox addNavigationDrawerItem(String title, SVG leftGraphic, Ru /// @return this list box public AdvancedListBox addNavigationDrawerTab(TabController controller, TabItem tab, String title, @Nullable SVG leftGraphic) { AdvancedListItem item = createNavigationDrawerItem(title, leftGraphic); - item.activeProperty().bind(controller.getSelectionModel().selectedItemProperty().isEqualTo(tab)); + item.activeProperty().bind(controller.selectedTabProperty().isEqualTo(tab)); item.setOnAction(e -> controller.select(tab)); return add(item); } @@ -95,7 +95,7 @@ public AdvancedListBox addNavigationDrawerTab(TabController controller, TabItem< public AdvancedListBox addNavigationDrawerTab(TabController controller, TabItem tab, String title, SVG unselectedGraphic, SVG selectedGraphic) { AdvancedListItem item = createNavigationDrawerItem(title, null); - item.activeProperty().bind(controller.getSelectionModel().selectedItemProperty().isEqualTo(tab)); + item.activeProperty().bind(controller.selectedTabProperty().isEqualTo(tab)); item.setOnAction(e -> controller.select(tab)); var leftGraphic = new SVGContainer(item.isActive() ? selectedGraphic : unselectedGraphic, AdvancedListItem.LEFT_ICON_SIZE); 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 index 5223c01495..116dd65196 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java @@ -2,25 +2,32 @@ // SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL package org.jackhuang.hmcl.ui.construct; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; -import javafx.scene.control.SingleSelectionModel; +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 [#select(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(); + /// The selected tab, or null when no tab is selected. + private final ReadOnlyObjectWrapper<@Nullable TabItem> selectedTab = new ReadOnlyObjectWrapper<>(this, "selectedTab"); /// Creates a controller with the supplied tabs and no content destination. /// @@ -35,76 +42,115 @@ public TabController(TabItem @Nullable ... tabs) { /// @param tabs the initial tabs in display order, or null for an empty controller public TabController(@Nullable TransitionPane contentPane, TabItem @Nullable ... tabs) { this.contentPane = contentPane; - - if (tabs != null) { - getTabs().addAll(tabs); - } + this.tabs.addListener(this::tabsChanged); + if (tabs != null) + this.tabs.addAll(tabs); } - /// The mutable tabs in display order. - private final ObservableList> tabs = FXCollections.observableArrayList(); - /// Returns the mutable, observable list of tabs in display order. - /// Entries must be non-null and distinct, and must not belong to another controller. - /// List changes update selection state without initializing or switching content. + /// 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 selection model that tracks changes to the tab list. - private final SingleSelectionModel<@Nullable TabItem> selectionModel = new TabSelectionModel(); + /// 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(); + } - /// Returns the selection model without activating content or invoking page callbacks. - /// Direct selection changes, clearing, and list-driven selection only update state. - /// Selecting a non-null item outside the list sets its selected index to -1; - /// selecting a null item or an out-of-range index leaves selection unchanged. - /// Use [#select(TabItem)] to initialize and display the selected content. - public SingleSelectionModel<@Nullable TabItem> getSelectionModel() { - return selectionModel; + /// Returns the read-only selected tab property; use [#select(TabItem)] to activate a tab. + public ReadOnlyObjectProperty<@Nullable TabItem> selectedTabProperty() { + return selectedTab.getReadOnlyProperty(); } - /// Selects the tab and switches its content with animation when content is already displayed. + /// Selects the tab and animates content replacement when content is already displayed. /// /// @see #select(TabItem, boolean) public void select(TabItem tab) { select(tab, true); } - /// Hides the previous page, initializes and shows the requested page, updates the - /// selection, and replaces the content of the configured transition pane. - /// Page callbacks are invoked even when the requested tab is already selected. - /// Callbacks apply to nodes implementing [PageAware]. When a content destination - /// is configured, the tab must provide a non-null node after initialization. - /// Without a destination, a tab may have no content. - /// Exceptions from factories, callbacks, or content replacement propagate; - /// completed callbacks, initialization, and selection changes are not rolled back. + /// 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. + /// + /// 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 the tab to select + /// @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 select(TabItem tab, boolean playAnimation) { - @Nullable TabItem oldTab = getSelectionModel().getSelectedItem(); - if (oldTab != null) { - if (oldTab.getNode() instanceof PageAware pageAware) { - pageAware.onPageHidden(); - } - } + 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 ? null : previous.getNode(); tab.initializeIfNeeded(); - if (tab.getNode() instanceof PageAware pageAware) { - pageAware.onPageShown(); - } - - getSelectionModel().select(tab); + @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(tab.getNode(), - ContainerAnimations.SLIDE_UP_FADE_IN, - Motion.MEDIUM4, - Motion.EASE_IN_OUT_CUBIC_EMPHASIZED - ); + contentPane.setContent(content, ContainerAnimations.SLIDE_UP_FADE_IN, + Motion.MEDIUM4, Motion.EASE_IN_OUT_CUBIC_EMPHASIZED); } else { - contentPane.setContent(tab.getNode(), ContainerAnimations.NONE); + 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()) + select(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); } } } @@ -112,177 +158,16 @@ public void select(TabItem tab, boolean playAnimation) { /// Forwards the shown callback to the selected content when it implements [PageAware]. @Override public void onPageShown() { - @Nullable TabItem tab = getSelectionModel().getSelectedItem(); - if (tab != null && tab.getNode() instanceof PageAware pageAware) { + @Nullable TabItem tab = getSelectedTab(); + if (tab != null && tab.getNode() instanceof PageAware pageAware) pageAware.onPageShown(); - } } /// Forwards the hidden callback to the selected content when it implements [PageAware]. @Override public void onPageHidden() { - @Nullable TabItem tab = getSelectionModel().getSelectedItem(); - if (tab != null && tab.getNode() instanceof PageAware pageAware) { + @Nullable TabItem tab = getSelectedTab(); + if (tab != null && tab.getNode() instanceof PageAware pageAware) pageAware.onPageHidden(); - } - } - - /// Synchronizes the selected index, selected item, and tab selection flags. - /// - /// Changes to the tab list update the selection. Selecting through this model - /// does not initialize content or invoke page lifecycle callbacks. - @NotNullByDefault - private final class TabSelectionModel extends SingleSelectionModel<@Nullable TabItem> { - /// Registers a listener that keeps the selection aligned with this controller's tab list. - private TabSelectionModel() { - // watching for changes to the items list content - final ListChangeListener> itemsContentObserver = c -> { - while (c.next()) { - for (@Nullable TabItem tab : c.getRemoved()) { - if (tab != null && !getTabs().contains(tab)) { - if (tab.isSelected()) { - tab.setSelected(false); - final int tabIndex = c.getFrom(); - - // Select a nearby tab after removing the selected tab. - selectNearestAvailableTab(tabIndex); - } - } - } - if (c.wasAdded() || c.wasRemoved() || c.wasPermutated()) { - // 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() != getTabs().indexOf(getSelectedItem())) { - clearAndSelect(getTabs().indexOf(getSelectedItem())); - } - } - } - if (getSelectedIndex() == -1 && getSelectedItem() == null && !getTabs().isEmpty()) { - // Select the first available tab when the list gains its first items. - selectNearestAvailableTab(0); - } else if (getTabs().isEmpty()) { - clearSelection(); - } - }; - getTabs().addListener(itemsContentObserver); - } - - /// Selects the tab at the given index; ignores indices outside the tab list. - /// Updates the selection flags of the old and new tabs. - @Override - public void select(int index) { - if (index < 0 || index >= getItemCount() || - (index == getSelectedIndex() && getModelItem(index).isSelected())) { - return; - } - - // Use the item rather than its former index, which may have shifted. - @Nullable TabItem oldTab = getSelectedItem(); - if (oldTab != null) - oldTab.setSelected(false); - - setSelectedIndex(index); - - @Nullable TabItem tab = getModelItem(index); - if (tab != null) { - setSelectedItem(tab); - } - - // Select the new tab - if (getSelectedIndex() >= 0 && getSelectedIndex() < getTabs().size()) { - getTabs().get(getSelectedIndex()).setSelected(true); - } - } - - /// Clears the selected item, index, and tab selection flag without switching content. - @Override - public void clearSelection() { - @Nullable TabItem oldTab = getSelectedItem(); - if (oldTab != null) - oldTab.setSelected(false); - setSelectedIndex(-1); - setSelectedItem(null); - } - - /// Selects a matching tab in the list. If a non-null tab is absent, updates - /// the selected item with index -1; a null argument leaves the selection unchanged. - @Override - public void select(@Nullable TabItem tab) { - final int itemCount = getItemCount(); - - for (int i = 0; i < itemCount; i++) { - final @Nullable TabItem value = getModelItem(i); - if (value != null && value.equals(tab)) { - select(i); - return; - } - } - if (tab != null) { - @Nullable TabItem oldTab = getSelectedItem(); - if (oldTab != null) - oldTab.setSelected(false); - setSelectedIndex(-1); - tab.setSelected(true); - setSelectedItem(tab); - } - } - - /// Returns the tab at the given index, or null if the index is out of range. - @Override - protected @Nullable TabItem getModelItem(int index) { - final ObservableList> items = getTabs(); - if (index < 0 || index >= items.size()) return null; - return items.get(index); - } - - /// Returns the current number of tabs in the controller. - @Override - protected int getItemCount() { - return getTabs().size(); - } - - /// Select the tab near a removed position, searching left before right at each distance. - /// - /// @param tabIndex the position from which a tab was removed - private void selectNearestAvailableTab(int tabIndex) { - // Search outwards from the removed position, preferring the left side. - final int tabCount = getItemCount(); - int i = 1; - @Nullable TabItem bestTab = null; - while (true) { - // look leftwards - int downPos = tabIndex - i; - if (downPos >= 0) { - @Nullable TabItem candidate = getModelItem(downPos); - if (candidate != null) { - bestTab = candidate; - 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) { - @Nullable TabItem candidate = getModelItem(upPos); - if (candidate != null) { - bestTab = candidate; - break; - } - } - - if (downPos < 0 && upPos >= tabCount) { - break; - } - i++; - } - - if (bestTab != null) { - select(bestTab); - } - } } } 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 14457798e3..69819e4032 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,7 +3,6 @@ package org.jackhuang.hmcl.ui.construct; import javafx.animation.*; -import javafx.beans.InvalidationListener; import javafx.beans.property.*; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; @@ -57,13 +56,6 @@ public final ObservableList> getTabs() { return controller.getTabs(); } - /// Returns the controller's selection model; direct changes do not activate content. - /// - /// @see TabController#getSelectionModel() - public final SingleSelectionModel<@Nullable TabItem> getSelectionModel() { - return controller.getSelectionModel(); - } - /// The lazily allocated tab placement property. private @Nullable ObjectProperty side; @@ -138,7 +130,8 @@ protected TabHeaderSkin(TabHeader control) { headers.addListener(headersListener); updateChildren(); - registerChangeListener(control.getSelectionModel().selectedItemProperty(), observable -> { + registerChangeListener(control.getController().selectedTabProperty(), observable -> { + headers.forEach(TabHeaderView::updateSelection); control.notifyAccessibleAttributeChanged(AccessibleAttribute.FOCUS_ITEM); animateSelection = true; control.requestLayout(); @@ -220,7 +213,7 @@ protected void layoutChildren(double contentX, double contentY, double contentWi double offset = 0; double selectedOffset = 0; double selectedLength = 0; - @Nullable TabItem selected = getSkinnable().getSelectionModel().getSelectedItem(); + @Nullable TabItem selected = getSkinnable().getController().getSelectedTab(); for (TabHeaderView child : headers) { if (!child.isVisible()) continue; double width = snapSizeX(child.prefWidth(-1)); @@ -299,8 +292,6 @@ private final class TabHeaderView extends RipplerContainer { private final TabItem tab; /// The label bound to the tab's text. private final Label tabText; - /// Updates selection styling until this view is removed or the skin is disposed. - private final InvalidationListener selectionListener = observable -> updateSelection(); /// Binds the label and selection styling and selects the tab when this view fires an action. private TabHeaderView(TabItem tab) { @@ -310,20 +301,19 @@ private TabHeaderView(TabItem tab) { tabText.textProperty().bind(tab.textProperty()); tabText.getStyleClass().add("tab-label"); tabText.setMouseTransparent(true); - tab.selectedProperty().addListener(selectionListener); updateSelection(); setOnAction(event -> getSkinnable().getController().select(tab)); } - /// Reflects the tab's selection flag in the label's pseudo-class. + /// Reflects the controller's selected tab in the label's pseudo-class. private void updateSelection() { - tabText.pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, tab.isSelected()); + tabText.pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, + getSkinnable().getController().getSelectedTab() == tab); } - /// Releases the tab's text binding and selection listener. + /// Releases the tab's text binding. private void dispose() { tabText.textProperty().unbind(); - tab.selectedProperty().removeListener(selectionListener); } } } 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 index 549925fe97..dabfcd2c6d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java @@ -9,24 +9,23 @@ import java.util.function.Supplier; -/// Stores a tab's label, selection state, and optionally lazily created content. +/// Stores a tab's label and optionally lazily created content. /// /// @param the type of content node @NotNullByDefault public final class TabItem { /// The nullable text displayed in the header. private final StringProperty text = new SimpleStringProperty(this, "text"); - /// Whether a selection model has marked this tab as selected. - private final ReadOnlyBooleanWrapper selected = new ReadOnlyBooleanWrapper(this, "selected"); /// The content node, or null when no content is assigned. private final ObjectProperty<@Nullable T> node = new SimpleObjectProperty<>(this, "node"); /// The optional factory used when content has not been assigned. private @Nullable Supplier nodeSupplier; - /// Creates an unselected tab with the given nullable identifier and no text or content. + /// 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; } @@ -56,21 +55,6 @@ public void setText(@Nullable String text) { this.text.set(text); } - /// Returns whether this tab is marked as selected. - public boolean isSelected() { - return selected.get(); - } - - /// Returns a read-only view of the tab's selection state. - public ReadOnlyBooleanProperty selectedProperty() { - return selected.getReadOnlyProperty(); - } - - /// Updates the tab's selection flag. - void setSelected(boolean selected) { - this.selected.set(selected); - } - /// Returns the content node, or null if none is assigned; does not invoke the factory. public @Nullable T getNode() { return node.get(); From 22a45c06045ec0c81253e03a2878c3f40b372857 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:36:51 +0800 Subject: [PATCH 17/24] Simplify TabController and TabHeader constructors by removing nullable annotations from varargs --- .../hmcl/ui/construct/TabController.java | 7 +++---- .../jackhuang/hmcl/ui/construct/TabHeader.java | 15 ++------------- 2 files changed, 5 insertions(+), 17 deletions(-) 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 index 116dd65196..a8b5d3c1f6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java @@ -32,7 +32,7 @@ public final class TabController implements PageAware { /// 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 @Nullable ... tabs) { + public TabController(TabItem ... tabs) { this(null, tabs); } @@ -40,11 +40,10 @@ public TabController(TabItem @Nullable ... tabs) { /// /// @param contentPane the destination updated by [#select(TabItem)], or null /// @param tabs the initial tabs in display order, or null for an empty controller - public TabController(@Nullable TransitionPane contentPane, TabItem @Nullable ... tabs) { + public TabController(@Nullable TransitionPane contentPane, TabItem ... tabs) { this.contentPane = contentPane; this.tabs.addListener(this::tabsChanged); - if (tabs != null) - this.tabs.addAll(tabs); + this.tabs.setAll(tabs); } /// Returns the mutable, observable list of tabs in display order. 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 69819e4032..3be13f45eb 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 @@ -17,8 +17,6 @@ import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.UnmodifiableView; -import java.util.Objects; - /// Displays selectable tab headers backed by a [TabController]. /// Activating a header delegates selection and content switching to its controller. @NotNullByDefault @@ -30,17 +28,8 @@ public class TabHeader extends Control { /// 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 @Nullable ... tabs) { - this(new TabController(tabs)); - } - - /// Creates a view of the supplied controller's tabs and selection state. - /// Creating or disposing a skin does not initialize or activate tab content. - /// - /// @param controller the controller shared with other navigation views - /// @throws NullPointerException if the controller is null - public TabHeader(TabController controller) { - this.controller = Objects.requireNonNull(controller); + public TabHeader(TabItem... tabs) { + this.controller = new TabController(tabs); getStyleClass().setAll("tab-header"); } From 53df8414cd390a8484eb2ccb04740e950db6b301 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:37:45 +0800 Subject: [PATCH 18/24] Refactor TabController to replace ReadOnlyObjectWrapper with SimpleObjectProperty for selected tab management --- .../hmcl/ui/construct/TabController.java | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) 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 index a8b5d3c1f6..989f6253ea 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java @@ -2,8 +2,9 @@ // 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.ReadOnlyObjectWrapper; +import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; @@ -24,10 +25,9 @@ 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(); - /// The selected tab, or null when no tab is selected. - private final ReadOnlyObjectWrapper<@Nullable TabItem> selectedTab = new ReadOnlyObjectWrapper<>(this, "selectedTab"); /// Creates a controller with the supplied tabs and no content destination. /// @@ -59,17 +59,20 @@ 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 [#select(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(); } - /// Returns the read-only selected tab property; use [#select(TabItem)] to activate a tab. - public ReadOnlyObjectProperty<@Nullable TabItem> selectedTabProperty() { - return selectedTab.getReadOnlyProperty(); - } - /// Selects the tab and animates content replacement when content is already displayed. /// /// @see #select(TabItem, boolean) From 2ea9eaf177bf57a5edf13ed53b8aa096482402de Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:39:29 +0800 Subject: [PATCH 19/24] Refactor LauncherSettingsPage and TerracottaPage to initialize TabItems directly in the constructor --- .../jackhuang/hmcl/ui/construct/TabItem.java | 5 --- .../hmcl/ui/main/LauncherSettingsPage.java | 42 +++++++++++-------- .../hmcl/ui/terracotta/TerracottaPage.java | 4 +- 3 files changed, 27 insertions(+), 24 deletions(-) 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 index dabfcd2c6d..0507a2905b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java @@ -35,11 +35,6 @@ public TabItem(Supplier nodeSupplier) { return nodeSupplier; } - /// Sets the content factory, or clears it if null. Existing content is unchanged. - public void setNodeSupplier(@Nullable Supplier nodeSupplier) { - this.nodeSupplier = nodeSupplier; - } - /// Returns the header text, which may be null. public @Nullable String getText() { return text.get(); 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 e69669c289..20a0dc10dd 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 @@ -25,33 +25,41 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor /// 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 = new TabItem<>(); + private final TabItem> gameTab; /// The tab whose content manages installed Java runtimes. - private final TabItem javaManagementTab = new TabItem<>(); + private final TabItem javaManagementTab; /// The tab for general launcher settings. - private final TabItem settingsTab = new TabItem<>(); + private final TabItem settingsTab; /// The tab for launcher appearance settings. - private final TabItem personalizationTab = new TabItem<>(); + private final TabItem personalizationTab; /// The tab for download settings. - private final TabItem downloadTab = new TabItem<>(); + private final TabItem downloadTab; /// The tab displaying launcher help. - private final TabItem helpTab = new TabItem<>(); + private final TabItem helpTab; /// The tab displaying launcher information. - private final TabItem aboutTab = new TabItem<>(); + private final TabItem aboutTab; /// The tab displaying feedback and contact information. - private final TabItem feedbackTab = new TabItem<>(); + 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); - tabController = new TabController(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); tabController.select(gameTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()))); 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 0b94a7dd41..d2d3e85fce 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 @@ -39,14 +39,14 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa /// 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 = new TabItem<>(); + private final TabItem statusPage; private final TransitionPane transitionPane = new TransitionPane(); @SuppressWarnings("unused") private @Nullable ChangeListener<@Nullable HMCLGameInstance> instanceChangeListenerHolder; public TerracottaPage() { - statusPage.setNodeSupplier(TerracottaControllerPage::new); + statusPage = new TabItem<>(TerracottaControllerPage::new); tabController = new TabController(transitionPane, statusPage); tabController.select(statusPage); From e9e696745ae16b97e05e13b0a96f47d63854465b Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:42:54 +0800 Subject: [PATCH 20/24] Refactor TabItem to simplify property management and lazy initialization --- .../jackhuang/hmcl/ui/construct/TabItem.java | 46 +++++++------------ 1 file changed, 17 insertions(+), 29 deletions(-) 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 index 0507a2905b..4d88193f66 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java @@ -14,11 +14,7 @@ /// @param the type of content node @NotNullByDefault public final class TabItem { - /// The nullable text displayed in the header. - private final StringProperty text = new SimpleStringProperty(this, "text"); - /// The content node, or null when no content is assigned. - private final ObjectProperty<@Nullable T> node = new SimpleObjectProperty<>(this, "node"); - /// The optional factory used when content has not been assigned. + private @Nullable T node; private @Nullable Supplier nodeSupplier; /// Creates a tab with no text, content, or content factory. @@ -30,44 +26,36 @@ public TabItem(Supplier nodeSupplier) { this.nodeSupplier = nodeSupplier; } - /// Returns the content factory, or null if none has been set. - public @Nullable Supplier getNodeSupplier() { - return nodeSupplier; - } - - /// Returns the header text, which may be null. - public @Nullable String getText() { - return text.get(); - } + /// 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) { - this.text.set(text); + if (this.text != null || text != null) + textProperty().set(text); } /// Returns the content node, or null if none is assigned; does not invoke the factory. public @Nullable T getNode() { - return node.get(); - } - - /// Returns the writable content property. A null value permits lazy initialization. - public ObjectProperty<@Nullable T> nodeProperty() { + initializeIfNeeded(); return node; } - /// Sets the content node, or clears it if null. Does not change the content factory. - public void setNode(@Nullable T node) { - this.node.set(node); - } - /// Returns whether a non-null content node is currently assigned. public boolean isInitialized() { - return getNode() != null; + return node != null; } /// Invokes the content factory and assigns its result if the content is null @@ -77,11 +65,11 @@ public boolean isInitialized() { /// @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 (getNode() == null) { - if (getNodeSupplier() == null) { + if (node == null) { + if (nodeSupplier == null) { return false; } - setNode(getNodeSupplier().get()); + node = nodeSupplier.get(); return true; } return false; From fe1685b5445594bcf9b211aa2e74c28a37a4713c Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:45:55 +0800 Subject: [PATCH 21/24] Rename select method to selectTab for consistency across tab management --- .../hmcl/ui/account/CreateAccountPane.java | 2 +- .../hmcl/ui/construct/AdvancedListBox.java | 4 ++-- .../hmcl/ui/construct/TabController.java | 16 ++++++++-------- .../jackhuang/hmcl/ui/construct/TabHeader.java | 2 +- .../jackhuang/hmcl/ui/download/DownloadPage.java | 12 ++++++------ .../hmcl/ui/instances/GameInstancePage.java | 6 +++--- .../hmcl/ui/instances/WorldManagePage.java | 2 +- .../hmcl/ui/main/LauncherSettingsPage.java | 6 +++--- .../hmcl/ui/terracotta/TerracottaPage.java | 2 +- 9 files changed, 26 insertions(+), 26 deletions(-) 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 02a3be2393..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 @@ -154,7 +154,7 @@ public CreateAccountPane(AccountFactory factory) { tabHeader.setMinWidth(USE_PREF_SIZE); tabHeader.setMaxWidth(USE_PREF_SIZE); if (selected != null) - tabHeader.getController().select(selected); + tabHeader.getController().selectTab(selected); onChange(tabHeader.getController().selectedTabProperty(), newItem -> { if (newItem == null) 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 4cbd7ce086..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 @@ -85,7 +85,7 @@ public AdvancedListBox addNavigationDrawerItem(String title, SVG leftGraphic, Ru public AdvancedListBox addNavigationDrawerTab(TabController controller, TabItem tab, String title, @Nullable SVG leftGraphic) { AdvancedListItem item = createNavigationDrawerItem(title, leftGraphic); item.activeProperty().bind(controller.selectedTabProperty().isEqualTo(tab)); - item.setOnAction(e -> controller.select(tab)); + item.setOnAction(e -> controller.selectTab(tab)); return add(item); } @@ -96,7 +96,7 @@ public AdvancedListBox addNavigationDrawerTab(TabController controller, TabItem< SVG unselectedGraphic, SVG selectedGraphic) { AdvancedListItem item = createNavigationDrawerItem(title, null); item.activeProperty().bind(controller.selectedTabProperty().isEqualTo(tab)); - item.setOnAction(e -> controller.select(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/TabController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java index 989f6253ea..df98d6ba86 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java @@ -18,7 +18,7 @@ import java.util.Objects; /// Manages tab selection, lazy content initialization, and page lifecycle callbacks. -/// An optional transition pane displays content activated by [#select(TabItem)]. +/// 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 @@ -38,7 +38,7 @@ public TabController(TabItem ... tabs) { /// Creates a controller with an optional destination for selected content. /// - /// @param contentPane the destination updated by [#select(TabItem)], or null + /// @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; @@ -62,7 +62,7 @@ public ObservableList> getTabs() { /// 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 [#select(TabItem)] to activate a tab. + /// Returns the read-only selected tab property; use [#selectTab(TabItem)] to activate a tab. public ReadOnlyObjectProperty<@Nullable TabItem> selectedTabProperty() { return selectedTab; } @@ -75,9 +75,9 @@ public ObservableList> getTabs() { /// Selects the tab and animates content replacement when content is already displayed. /// - /// @see #select(TabItem, boolean) - public void select(TabItem tab) { - select(tab, true); + /// @see #selectTab(TabItem, boolean) + public void selectTab(TabItem tab) { + selectTab(tab, true); } /// Initializes the tab's content, hides the previous page, replaces the configured @@ -96,7 +96,7 @@ public void select(TabItem tab) { /// @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 select(TabItem tab, boolean playAnimation) { + 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"); @@ -139,7 +139,7 @@ private void tabsChanged(ListChangeListener.Change> change) // Clear the removed page first so a failing replacement cannot leave it selected. clearSelection(); if (!tabs.isEmpty()) - select(tabs.get(Math.max(0, Math.min(removedIndex - 1, tabs.size() - 1))), false); + selectTab(tabs.get(Math.max(0, Math.min(removedIndex - 1, tabs.size() - 1))), false); } } 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 3be13f45eb..c5468b52a8 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 @@ -291,7 +291,7 @@ private TabHeaderView(TabItem tab) { tabText.getStyleClass().add("tab-label"); tabText.setMouseTransparent(true); updateSelection(); - setOnAction(event -> getSkinnable().getController().select(tab)); + setOnAction(event -> getSkinnable().getController().selectTab(tab)); } /// Reflects the controller's selected tab in the label's pseudo-class. 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 d2dffe8360..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 @@ -122,7 +122,7 @@ public DownloadPage(GameInstanceID uploadInstance) { GameDirectoryManager.registerVersionsListener(this::loadVersions); - tabController.select(newGameTab); + tabController.selectTab(newGameTab); AdvancedListBox sideBar = new AdvancedListBox() .startCategory(i18n("download.game").toUpperCase(Locale.ROOT)) @@ -215,25 +215,25 @@ public ReadOnlyObjectProperty stateProperty() { } public void showGameDownloads() { - tabController.select(newGameTab, false); + tabController.selectTab(newGameTab, false); } public void showModpackDownloads() { - tabController.select(modpackTab, false); + tabController.selectTab(modpackTab, false); } public DownloadListPage showResourcePackDownloads() { - tabController.select(resourcePackTab, false); + tabController.selectTab(resourcePackTab, false); return resourcePackTab.getNode(); } public DownloadListPage showModDownloads() { - tabController.select(modTab, false); + tabController.selectTab(modTab, false); return modTab.getNode(); } public void showWorldDownloads() { - tabController.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 765af47178..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 @@ -96,7 +96,7 @@ public GameInstancePage() { worldListTab, schematicsTab, screenshotsTab); - tabController.select(gameSettingsTab); + tabController.selectTab(gameSettingsTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -167,7 +167,7 @@ private void checkSelectedInstance() { } public void showInstanceSettings() { - tabController.select(gameSettingsTab, false); + tabController.selectTab(gameSettingsTab, false); } public void setInstance(GameInstanceID instanceId, HMCLGameRepository repository) { @@ -391,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.tabController.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 8fc79176bd..843e90930a 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 @@ -197,7 +197,7 @@ private AdvancedListBox getTabBar() { AdvancedListBox tabBar = new AdvancedListBox(); { getSkinnable().tabController.getTabs().addAll(getSkinnable().worldInfoTab, getSkinnable().worldBackupsTab); - getSkinnable().tabController.select(getSkinnable().worldInfoTab); + getSkinnable().tabController.selectTab(getSkinnable().worldInfoTab); 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); 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 20a0dc10dd..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 @@ -61,7 +61,7 @@ public LauncherSettingsPage() { feedbackTab, aboutTab); - tabController.select(gameTab); + tabController.selectTab(gameTab); addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()))); AdvancedListBox sideBar = new AdvancedListBox() @@ -93,11 +93,11 @@ public void onPageHidden() { public void showGameSettings(HMCLGameRepository repository) { gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); - tabController.select(gameTab, false); + tabController.selectTab(gameTab, false); } public void showFeedback() { - tabController.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 d2d3e85fce..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 @@ -48,7 +48,7 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa public TerracottaPage() { statusPage = new TabItem<>(TerracottaControllerPage::new); tabController = new TabController(transitionPane, statusPage); - tabController.select(statusPage); + tabController.selectTab(statusPage); BorderPane left = new BorderPane(); FXUtils.setLimitWidth(left, 200); From f576ecf56fd5f0e72757999d6343758c3c6a92b9 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:48:04 +0800 Subject: [PATCH 22/24] Update TabHeader to access controller's tab list directly for improved clarity --- .../java/org/jackhuang/hmcl/ui/construct/TabHeader.java | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) 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 c5468b52a8..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 @@ -38,13 +38,6 @@ public final TabController getController() { return controller; } - /// Returns the controller's mutable tab list in display order. - /// - /// @see TabController#getTabs() - public final ObservableList> getTabs() { - return controller.getTabs(); - } - /// The lazily allocated tab placement property. private @Nullable ObjectProperty side; @@ -115,7 +108,7 @@ protected TabHeaderSkin(TabHeader control) { lineOffset.addListener(observable -> control.requestLayout()); lineLength.addListener(observable -> control.requestLayout()); - headers = MappedObservableList.create(getSkinnable().getTabs(), TabHeaderView::new); + headers = MappedObservableList.create(getSkinnable().controller.getTabs(), TabHeaderView::new); headers.addListener(headersListener); updateChildren(); From d391cbd5511f0c6424f63625b307dd2326e0eb6e Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 01:58:59 +0800 Subject: [PATCH 23/24] Avoid initializing unvisited world tabs during refresh Assisted-by: codex:gpt-6-astra --- .../main/java/org/jackhuang/hmcl/ui/construct/TabItem.java | 4 +++- .../java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 index 4d88193f66..34c253ea59 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabItem.java @@ -47,7 +47,9 @@ public void setText(@Nullable String text) { textProperty().set(text); } - /// Returns the content node, or null if none is assigned; does not invoke the factory. + /// 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; 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 843e90930a..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 @@ -86,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(); @@ -98,7 +99,7 @@ public void refresh() { } for (var tab : tabController.getTabs()) { - if (tab.getNode() instanceof WorldRefreshable r) { + if (tab.isInitialized() && tab.getNode() instanceof WorldRefreshable r) { r.refresh(); } } From 5288562dafb2792b2f60ff6a425e85baaae3fb8c Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 28 Oct 2026 02:06:38 +0800 Subject: [PATCH 24/24] Avoid initializing unvisited tabs during selection and lifecycle callbacks Assisted-by: codex:gpt-6-astra --- .../jackhuang/hmcl/ui/construct/TabController.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 index df98d6ba86..4362848723 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabController.java @@ -84,6 +84,7 @@ public void selectTab(TabItem tab) { /// 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. @@ -102,8 +103,7 @@ public void selectTab(TabItem tab, boolean playAnimation) { throw new IllegalArgumentException("Tab does not belong to this controller"); @Nullable TabItem previous = getSelectedTab(); - @Nullable Node previousContent = previous == null ? null : previous.getNode(); - tab.initializeIfNeeded(); + @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"); @@ -157,19 +157,21 @@ private void clearSelection() { } } - /// Forwards the shown callback to the selected content when it implements [PageAware]. + /// 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.getNode() instanceof PageAware pageAware) + if (tab != null && tab.isInitialized() && tab.getNode() instanceof PageAware pageAware) pageAware.onPageShown(); } - /// Forwards the hidden callback to the selected content when it implements [PageAware]. + /// 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.getNode() instanceof PageAware pageAware) + if (tab != null && tab.isInitialized() && tab.getNode() instanceof PageAware pageAware) pageAware.onPageHidden(); } }