Skip to content
1 change: 1 addition & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public enum SVG {
INFO("M11 17H13V11H11V17ZM12 9Q12.425 9 12.7125 8.7125T13 8Q13 7.575 12.7125 7.2875T12 7Q11.575 7 11.2875 7.2875T11 8Q11 8.425 11.2875 8.7125T12 9ZM12 22Q9.925 22 8.1 21.2125T4.925 19.075Q3.575 17.725 2.7875 15.9T2 12Q2 9.925 2.7875 8.1T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2Q14.075 2 15.9 2.7875T19.075 4.925Q20.425 6.275 21.2125 8.1T22 12Q22 14.075 21.2125 15.9T19.075 19.075Q17.725 20.425 15.9 21.2125T12 22ZM12 20Q15.35 20 17.675 17.675T20 12Q20 8.65 17.675 6.325T12 4Q8.65 4 6.325 6.325T4 12Q4 15.35 6.325 17.675T12 20ZM12 12Z"),
INFO_FILL("M12 22q2.075-0 3.9-.7875t3.175-2.1375q1.35-1.35 2.1375-3.175T22 12q-0-2.075-.7875-3.9T19.075 4.925q-1.35-1.35-3.175-2.1375T12 2q-2.075 0-3.9.7875T4.925 4.925Q3.575 6.275 2.7875 8.1T2 12q0 2.075.7875 3.9T4.925 19.075q1.35 1.35 3.175 2.1375T12 22ZM12 9q-.425 0-.7125-.2875T11 8t.2875-.7125T12 7q.425-0 .7125.2875T13 8t-.2875.7125T12 9Zm-1 8V11h2v6H11Z"),
KEEP("m16 12l2 2v2h-5v6l-1 1l-1-1v-6H6v-2l2-2V5H7V3h10v2h-1zm-7.15 2h6.3L14 12.85V5h-4v7.85zM12 14"),
KEEP_FILL("m16 12l2 2v2h-5v6l-1 1l-1-1v-6H6v-2l2-2V5H7V3h10v2h-1z"),
KEYBOARD_ARROW_DOWN("M12 15.4 6 9.4 7.4 8 12 12.6 16.6 8 18 9.4 12 15.4Z"),
KEYBOARD_ARROW_UP("M12 10.8 7.4 15.4 6 14 12 8 18 14 16.6 15.4 12 10.8Z"),
LANDSCAPE("M1 18l6-8 4.5 6H19L14 9.35l-2.5 3.3L10.25 11 14 6l9 12H1Zm13.025-2ZM5 16H9L7 13.325 5 16ZH9 5Z"),
Expand Down
177 changes: 172 additions & 5 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
private final ReentrantLock lock = new ReentrantLock();
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();

/// Notified after the pin state of any mod changed, so the skin refreshes its toolbar button.
private @Nullable Runnable pinnedStateChanged;

private ModManager modManager;
private @Nullable HMCLGameInstance gameInstance;

Expand Down Expand Up @@ -156,7 +159,9 @@ private void loadMods(ModManager modManager) {
lock.lock();
try {
modManager.refresh();
return modManager.getLocalFiles().stream().map(ModInfoObject::new).toList();
return modManager.getLocalFiles().stream()
.map(file -> new ModInfoObject(file, modManager.isPinned(file.getFile())))
.toList();
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
Expand Down Expand Up @@ -257,18 +262,91 @@ public void openModFolder() {
}
}

/// Pins or unpins a mod, so that the update check skips it.
///
/// @param item the mod to change
/// @param pinned whether the mod should stay at its current version
void setPinned(ModInfoObject item, boolean pinned) {
setPinned(List.of(item), pinned);
}

/// Pins or unpins several mods at once, saving them with a single write.
///
/// @param items the mods to change
/// @param pinned whether the mods should stay at their current versions
void setSelectedPinned(ObservableList<ModInfoObject> items, boolean pinned) {
setPinned(items, pinned);
}

/// Applies a pin change to the list immediately, then saves it in the background.
///
/// @param items the mods to change
/// @param pinned whether the mods should stay at their current versions
private void setPinned(Collection<ModInfoObject> items, boolean pinned) {
ModManager manager = modManager;
// While the list reloads, the items still belong to the previous mod directory.
if (manager == null || isLoading()) {
return;
}

List<Path> files = items.stream()
.map(item -> item.getModInfo().getFile())
.toList();
for (ModInfoObject item : items) {
item.setPinned(pinned);
}
firePinnedStateChanged();

Task.runAsync(() -> manager.setPinned(files, pinned))
.whenComplete(Schedulers.javafx(), exception -> {
if (exception != null) {
LOG.warning("Unable to save pinned mods", exception);
Controllers.showToast(i18n("message.failed"));
}
})
.start();
}

/// Notifies [#pinnedStateChanged] that the pin state of some mod changed.
private void firePinnedStateChanged() {
Runnable listener = pinnedStateChanged;
if (listener != null) {
listener.run();
}
}

/// Returns whether the mod stays at its current version during update checks.
///
/// @param file the mod file
/// @return true if the update check skips the mod
boolean isPinned(LocalModFile file) {
ModManager manager = modManager;
return manager != null && manager.isPinned(file.getFile());
}

/// Checks for updates of the given mods, skipping the ones pinned to their current version.
///
/// @param mods the mods to check
public void checkUpdates(Collection<LocalModFile> mods) {
Objects.requireNonNull(mods);
if (isLoading() || gameInstance == null) {
return;
}

List<LocalModFile> targets = mods.stream()
.filter(mod -> !isPinned(mod))
.toList();
if (targets.isEmpty()) {
Controllers.dialog(i18n("mods.pin.nothing"));
return;
}

HMCLGameInstance gameInstance = this.gameInstance;
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
GameVersionNumber version = gameInstance.getVersion();
if (version != GameVersionNumber.unknown()) {
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), mods);
return new AddonCheckUpdatesTask(DownloadProviders.getDownloadProvider(), version.toString(), targets);
} else {
LOG.warning("Failed to check for updates, due to unable to get instance game version");
return null;
Expand Down Expand Up @@ -335,6 +413,9 @@ private static final class ModListPageSkin extends SkinBase<ModListPage> {
private final HBox toolbarNormal;
private final HBox toolbarSelecting;

/// Toolbar button of [#toolbarSelecting] that pins or unpins the selected mods.
private final JFXButton pinSelected;

private final JFXListView<ModListPage.ModInfoObject> listView;

/// Whether the search mechanism is currently active.
Expand Down Expand Up @@ -415,9 +496,17 @@ public ModListPageSkin(ModListPage skinnable) {
// reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker
var selectAll = createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> listView.getSelectionModel().selectRange(0, listView.getItems().size()));

// Pins or unpins every selected mod at once, the batch equivalent of the pin icon of a row.
pinSelected = createToolbarButton2(i18n("mods.pin"), SVG.KEEP, () -> {
skinnable.setSelectedPinned(listView.getSelectionModel().getSelectedItems(), !allPinned());
updatePinSelectedButton();
});
skinnable.pinnedStateChanged = this::updatePinSelectedButton;

ListChangeListener<Object> listener = change -> {
selectAll.setDisable(!listView.getItems().isEmpty()
&& listView.getSelectionModel().getSelectedItems().size() == listView.getItems().size());
updatePinSelectedButton();
};

listView.getSelectionModel().getSelectedItems().addListener(listener);
Expand All @@ -433,6 +522,7 @@ public ModListPageSkin(ModListPage skinnable) {
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () ->
skinnable.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
pinSelected,
createToolbarButton2(i18n("addon.check_update.button"), SVG.UPDATE, () ->
skinnable.checkUpdates(
listView.getSelectionModel().getSelectedItems().stream()
Expand Down Expand Up @@ -528,6 +618,21 @@ private void changeToolbar(HBox newToolbar) {
}
}

/// Returns whether every selected mod is already pinned, so [#pinSelected] unpins instead of pins.
///
/// @return true if the selection is not empty and all of its mods are pinned
private boolean allPinned() {
ObservableList<ModListPage.ModInfoObject> selected = listView.getSelectionModel().getSelectedItems();
return !selected.isEmpty() && selected.stream().allMatch(ModListPage.ModInfoObject::isPinned);
}

/// Makes the text and glyph of [#pinSelected] describe what the next click will do.
private void updatePinSelectedButton() {
boolean allPinned = allPinned();
pinSelected.setText(i18n(allPinned ? "mods.unpin" : "mods.pin"));
pinSelected.setGraphic((allPinned ? SVG.KEEP_FILL : SVG.KEEP).createIcon(20));
}

private void search() {
isSearching.set(true);

Expand Down Expand Up @@ -570,11 +675,15 @@ public static final class ModInfoObject {
/// Matching project metadata, or null if the local mod cannot be identified.
private final @Nullable ModProjectInfo projectInfo;

/// Whether this mod stays at its current version during update checks.
private final SimpleBooleanProperty pinned;

private final ItemPropertyAsyncCache<Image, ModInfoObject> iconCache;

ModInfoObject(LocalModFile localModFile) {
ModInfoObject(LocalModFile localModFile, boolean pinned) {
this.localModFile = localModFile;
this.projectInfo = AddonProjectInfoRegistry.MOD.getInfo(localModFile.getId(), localModFile.getName());
this.pinned = new SimpleBooleanProperty(this, "pinned", pinned);

this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon);
}
Expand All @@ -583,6 +692,27 @@ public LocalModFile getModInfo() {
return localModFile;
}

/// Returns whether the mod stays at its current version when checking for updates.
///
/// @return true if the mod is pinned
public boolean isPinned() {
return pinned.get();
}

/// Returns the observable pin state, so list cells follow the changes made elsewhere.
///
/// @return the read-only pin state
public ReadOnlyBooleanProperty pinnedProperty() {
return pinned;
}

/// Records the pin state, which [#setPinned(ModInfoObject, boolean)] keeps in sync with the file.
///
/// @param pinned the new pin state
private void setPinned(boolean pinned) {
this.pinned.set(pinned);
}

/// Returns matching project metadata, or null if no project was found.
public @Nullable ModProjectInfo getProjectInfo() {
return projectInfo;
Expand Down Expand Up @@ -744,6 +874,9 @@ private static final class ModInfoListCell extends MDListCell<ModInfoObject> {
private final JFXCheckBox checkBox = new JFXCheckBox();
private final ImageContainer imageContainer = new ImageContainer(32);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton pinButton = FXUtils.newToggleButton4(SVG.KEEP);
/// Shared tooltip of [#pinButton], whose text follows the pin state of the displayed mod.
private final Tooltip pinTooltip = new Tooltip(i18n("mods.pin"));
private final JFXButton restoreButton = FXUtils.newToggleButton4(SVG.RESTORE);
private final JFXButton infoButton = FXUtils.newToggleButton4(SVG.INFO);
private final JFXButton revealButton = FXUtils.newToggleButton4(SVG.FOLDER);
Expand All @@ -752,6 +885,10 @@ private static final class ModInfoListCell extends MDListCell<ModInfoObject> {
private final WeakInvalidationListener weakActiveListener = new WeakInvalidationListener(activeListener);
private @Nullable ReadOnlyBooleanProperty observedActiveProperty;

private final InvalidationListener pinnedListener = obs -> applyPinState(getItem());
private final WeakInvalidationListener weakPinnedListener = new WeakInvalidationListener(pinnedListener);
private @Nullable ReadOnlyBooleanProperty observedPinnedProperty;

private Tooltip warningTooltip;

ModInfoListCell(JFXListView<ModInfoObject> listView, ModListPage page) {
Expand All @@ -769,9 +906,10 @@ private static final class ModInfoListCell extends MDListCell<ModInfoObject> {

imageContainer.setImage(GameInstanceIconType.COMMAND.getIcon());

FXUtils.installFastTooltip(pinButton, pinTooltip);
FXUtils.installFastTooltip(restoreButton, i18n("mods.restore"));

container.getChildren().setAll(checkBox, imageContainer, content, restoreButton, revealButton, infoButton);
container.getChildren().setAll(checkBox, imageContainer, content, restoreButton, pinButton, revealButton, infoButton);

StackPane.setMargin(container, new Insets(8));
getContainer().getChildren().setAll(container);
Expand All @@ -781,6 +919,8 @@ private static final class ModInfoListCell extends MDListCell<ModInfoObject> {
protected void updateControl(ModInfoObject dataItem, boolean empty) {
if (observedActiveProperty != null)
observedActiveProperty.removeListener(weakActiveListener);
if (observedPinnedProperty != null)
observedPinnedProperty.removeListener(weakPinnedListener);

pseudoClassStateChanged(WARNING, false);
if (warningTooltip != null) {
Expand Down Expand Up @@ -855,7 +995,10 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) {
checkBox.setSelected(modInfo.isActive());
}
});
restoreButton.setVisible(!modInfo.getMod().getOldFiles().isEmpty());
// Managed as well, so the pin icon keeps the same place whether or not the mod has an old file to restore.
boolean hasOldFiles = !modInfo.getMod().getOldFiles().isEmpty();
restoreButton.setVisible(hasOldFiles);
restoreButton.setManaged(hasOldFiles);
restoreButton.setOnAction(e -> {
menu.get().getContent().setAll(modInfo.getMod().getOldFiles().stream()
.map(localModFile -> new IconedMenuItem(null, localModFile.getVersion(),
Expand All @@ -869,6 +1012,11 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) {
revealButton.setOnAction(e -> FXUtils.showFileInExplorer(modInfo.getFile()));
infoButton.setOnAction(e -> Controllers.dialog(new ModInfoDialog(dataItem)));

pinButton.setDisable(modInfo.isOld());
(observedPinnedProperty = dataItem.pinnedProperty()).addListener(weakPinnedListener);
applyPinState(dataItem);
pinButton.setOnAction(e -> page.setPinned(dataItem, !dataItem.isPinned()));

if (!warning.isEmpty()) {
pseudoClassStateChanged(WARNING, true);

Expand All @@ -879,5 +1027,24 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) {
FXUtils.installFastTooltip(this, warningTooltip);
}
}

/// Rebuilds the pin icon, tag and tooltip of the displayed mod.
///
/// @param item the mod shown by this cell, or null when the cell is empty
private void applyPinState(@Nullable ModInfoObject item) {
if (item == null) {
return;
}

String text = i18n("mods.pinned");
content.getTags().removeIf(label -> text.equals(label.getText()));
if (item.isPinned()) {
content.addTag(text);
}
// A pinned mod shows the filled glyph, so the state is visible without reading the tag.
pinButton.setGraphic((item.isPinned() ? SVG.KEEP_FILL : SVG.KEEP).createIcon());
pinTooltip.setText(i18n(item.isPinned() ? "mods.unpin" : "mods.pin"));
}

}
}
4 changes: 4 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,10 @@ mods.mcmod=MCMod
mods.mcmod.page=MCMod Page
mods.mcmod.search=Search in MCMod
mods.name=Name
mods.pin=Pin to current version
mods.pin.nothing=All selected mods are pinned, so there is nothing to update.
mods.pinned=Pinned
mods.unpin=Unpin (allow update)
mods.restore=Restore
mods.toggle.failed=Failed to enable or disable mod(s). The mod file(s) may be in use by another program.
mods.url=Official Page
Expand Down
4 changes: 4 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_ar.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,10 @@ mods.mcmod=MCMod
mods.mcmod.page=صفحة MCMod
mods.mcmod.search=البحث في MCMod
mods.name=الاسم
mods.pin=تثبيت على الإصدار الحالي
mods.pin.nothing=جميع المودات المحددة مثبتة بالفعل، لا يوجد ما يمكن تحديثه.
mods.pinned=مثبَّت
mods.unpin=إلغاء التثبيت (السماح بالتحديث)
mods.restore=استعادة
# mods.toggle.failed=
mods.url=الصفحة الرسمية
Expand Down
4 changes: 4 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,10 @@ mods.mcmod=MCMod
mods.mcmod.page=MCMod-Seite
mods.mcmod.search=In MCMod suchen
mods.name=Name
mods.pin=Auf aktuelle Version festlegen
mods.pin.nothing=Alle ausgewählten Mods sind bereits festgelegt, es gibt nichts zu aktualisieren.
mods.pinned=Festgelegt
mods.unpin=Festlegung aufheben (Updates erlauben)
mods.restore=Wiederherstellen
# mods.toggle.failed=
mods.url=Offizielle Seite
Expand Down
4 changes: 4 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_es.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,10 @@ mods.mcmod=MCMod
mods.mcmod.page=Página de MCMod
mods.mcmod.search=Búsqueda en MCMod
mods.name=Nombre
mods.pin=Fijar a la versión actual
mods.pin.nothing=Todos los mods seleccionados ya están fijados, no hay nada que actualizar.
mods.pinned=Fijado
mods.unpin=Desfijar (permitir la actualización)
mods.restore=Restaurar
# mods.toggle.failed=
mods.url=Página oficial
Expand Down
4 changes: 4 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_ja.properties
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,10 @@ mods.mcmod=MCMOD
mods.mcmod.page=MCMOD
mods.mcmod.search=MCMODで検索
mods.name=Name
mods.pin=現在のバージョンに固定
mods.pin.nothing=選択した Mod はすべて固定済みのため、更新する項目はありません。
mods.pinned=固定中
mods.unpin=固定を解除(更新を許可)
mods.restore=Restore
# mods.toggle.failed=
mods.url=公式ページ
Expand Down
4 changes: 4 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_lzh.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,10 @@ mods.mcmod=礦藝改囊典
mods.mcmod.page=礦藝改囊典頁
mods.mcmod.search=礦藝改囊典查
mods.name=名
mods.pin=錮之(不與更迭)
mods.pin.nothing=所擇之囊皆已錮,無可更者。
mods.pinned=已錮
mods.unpin=解錮(復其更迭)
mods.restore=復
# mods.toggle.failed=
mods.url=改囊官網
Expand Down
4 changes: 4 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_ru.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,10 @@ mods.mcmod=MCMod
mods.mcmod.page=Страница MCMod
mods.mcmod.search=Искать на MCMod
mods.name=Название
mods.pin=Закрепить текущую версию
mods.pin.nothing=Все выбранные моды уже закреплены, обновлять нечего.
mods.pinned=Закреплён
mods.unpin=Открепить (разрешить обновление)
mods.restore=Восстановить
# mods.toggle.failed=
mods.url=Официальная страница
Expand Down
Loading
Loading