From 170189c34426068114f4e7b5a5d9a10323d92e79 Mon Sep 17 00:00:00 2001 From: yushijinhun Date: Fri, 20 Jul 2018 20:13:26 +0800 Subject: [PATCH] Refactor accounts --- .../hmcl/event/AccountAddedEvent.java | 57 ------ .../hmcl/event/AccountLoadingEvent.java | 60 ------ .../jackhuang/hmcl/game/AccountHelper.java | 4 +- .../org/jackhuang/hmcl/setting/Accounts.java | 180 +++++++++++++++- .../org/jackhuang/hmcl/setting/Config.java | 6 +- .../org/jackhuang/hmcl/setting/Settings.java | 156 +------------- .../org/jackhuang/hmcl/ui/AccountPage.java | 6 +- .../org/jackhuang/hmcl/ui/AddAccountPane.java | 31 +-- .../jackhuang/hmcl/ui/LeftPaneController.java | 192 ++++++++++-------- .../java/org/jackhuang/hmcl/ui/MainPage.java | 13 +- .../hmcl/ui/export/ModpackInfoPage.java | 4 +- .../resources/assets/lang/I18N.properties | 1 - .../resources/assets/lang/I18N_zh.properties | 1 - .../assets/lang/I18N_zh_CN.properties | 1 - .../AuthlibInjectorAccount.java | 13 ++ .../hmcl/auth/offline/OfflineAccount.java | 13 ++ .../hmcl/auth/yggdrasil/YggdrasilAccount.java | 12 ++ .../hmcl/util/MappedObservableList.java | 160 +++++++++++++++ 18 files changed, 503 insertions(+), 407 deletions(-) delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/event/AccountAddedEvent.java delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/event/AccountLoadingEvent.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/util/MappedObservableList.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/event/AccountAddedEvent.java b/HMCL/src/main/java/org/jackhuang/hmcl/event/AccountAddedEvent.java deleted file mode 100644 index 70450d32b..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/event/AccountAddedEvent.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Hello Minecraft! Launcher. - * Copyright (C) 2018 huangyuhui - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see {http://www.gnu.org/licenses/}. - */ -package org.jackhuang.hmcl.event; - -import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.util.ToStringBuilder; - -/** - * This event gets fired when added accounts. - *
- * This event is fired on the {@link org.jackhuang.hmcl.event.EventBus#EVENT_BUS} - * - * @author huangyuhui - */ -public class AccountAddedEvent extends Event { - - private final Account account; - - /** - * Constructor. - * - * @param source {@link org.jackhuang.hmcl.setting.Settings} - */ - public AccountAddedEvent(Object source, Account account) { - super(source); - this.account = account; - } - - - public Account getAccount() { - return account; - } - - @Override - public String toString() { - return new ToStringBuilder(this) - .append("source", source) - .append("account", account) - .toString(); - } -} - diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/event/AccountLoadingEvent.java b/HMCL/src/main/java/org/jackhuang/hmcl/event/AccountLoadingEvent.java deleted file mode 100644 index de4fdc167..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/event/AccountLoadingEvent.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Hello Minecraft! Launcher. - * Copyright (C) 2018 huangyuhui - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see {http://www.gnu.org/licenses/}. - */ -package org.jackhuang.hmcl.event; - -import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.util.ToStringBuilder; - -import java.util.Collection; -import java.util.Collections; - -/** - * This event gets fired when loading accounts. - *
- * This event is fired on the {@link org.jackhuang.hmcl.event.EventBus#EVENT_BUS} - * - * @author huangyuhui - */ -public class AccountLoadingEvent extends Event { - - private final Collection accounts; - - /** - * Constructor. - * - * @param source {@link org.jackhuang.hmcl.setting.Settings} - */ - public AccountLoadingEvent(Object source, Collection accounts) { - super(source); - this.accounts = Collections.unmodifiableCollection(accounts); - } - - - public Collection getAccounts() { - return accounts; - } - - @Override - public String toString() { - return new ToStringBuilder(this) - .append("source", source) - .append("accounts", accounts) - .toString(); - } -} - diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/AccountHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/AccountHelper.java index d60284fa0..faef60043 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/AccountHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/AccountHelper.java @@ -24,7 +24,7 @@ import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; import org.jackhuang.hmcl.auth.yggdrasil.Texture; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; -import org.jackhuang.hmcl.setting.Settings; +import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Scheduler; import org.jackhuang.hmcl.task.Schedulers; @@ -42,7 +42,7 @@ public final class AccountHelper { public static final File SKIN_DIR = new File(Launcher.HMCL_DIRECTORY, "skins"); public static void loadSkins() { - for (Account account : Settings.INSTANCE.getAccounts()) { + for (Account account : Accounts.getAccounts()) { if (account instanceof YggdrasilAccount) { new SkinLoadTask((YggdrasilAccount) account, false).start(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java index 7a7fb6e8a..17e6a6551 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java @@ -1,7 +1,7 @@ /* * Hello Minecraft! Launcher. * Copyright (C) 2018 huangyuhui - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or @@ -29,14 +29,27 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory; import org.jackhuang.hmcl.auth.yggdrasil.MojangYggdrasilProvider; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccountFactory; + +import javafx.beans.Observable; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyListProperty; +import javafx.beans.property.ReadOnlyListWrapper; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.ObservableList; + import java.io.IOException; import java.util.Map; +import java.util.Optional; import java.util.logging.Level; +import static java.util.stream.Collectors.toList; +import static javafx.collections.FXCollections.observableArrayList; import static org.jackhuang.hmcl.setting.ConfigHolder.CONFIG; +import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Logging.LOG; import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; /** * @author huangyuhui @@ -54,13 +67,12 @@ public final class Accounts { private static final String TYPE_YGGDRASIL_ACCOUNT = "yggdrasil"; private static final String TYPE_AUTHLIB_INJECTOR = "authlibInjector"; - static final Map> TYPE_TO_ACCOUNT_FACTORY = mapOf( + private static Map> type2factory = mapOf( pair(TYPE_OFFLINE, FACTORY_OFFLINE), pair(TYPE_YGGDRASIL_ACCOUNT, FACTORY_YGGDRASIL), - pair(TYPE_AUTHLIB_INJECTOR, FACTORY_AUTHLIB_INJECTOR) - ); + pair(TYPE_AUTHLIB_INJECTOR, FACTORY_AUTHLIB_INJECTOR)); - static String getAccountType(Account account) { + private static String accountType(Account account) { if (account instanceof OfflineAccount) return TYPE_OFFLINE; else if (account instanceof AuthlibInjectorAccount) @@ -71,14 +83,134 @@ public final class Accounts { throw new IllegalArgumentException("Failed to determine account type: " + account); } - static String getAccountId(Account account) { - return getAccountId(account.getUsername(), account.getCharacter()); + public static AccountFactory getAccountFactory(Account account) { + return type2factory.get(accountType(account)); } - static String getAccountId(String username, String character) { - return username + ":" + character; + private static String accountId(Account account) { + return account.getUsername() + ":" + account.getCharacter(); } + private static ObservableList accounts = observableArrayList(account -> new Observable[] { account }); + private static ReadOnlyListProperty accountsWrapper = new ReadOnlyListWrapper<>(accounts); + + private static ObjectProperty selectedAccount = new SimpleObjectProperty() { + { + accounts.addListener(onInvalidating(this::invalidated)); + } + + @Override + protected void invalidated() { + // this methods first checks whether the current selection is valid + // if it's valid, the underlying storage will be updated + // otherwise, the first account will be selected as an alternative(or null if accounts is empty) + Account selected = get(); + if (accounts.isEmpty()) { + if (selected == null) { + // valid + } else { + // the previously selected account is gone, we can only set it to null here + set(null); + return; + } + } else { + if (accounts.contains(selected)) { + // valid + } else { + // the previously selected account is gone + set(accounts.get(0)); + return; + } + } + // selection is valid, store it + if (!initialized) + return; + CONFIG.setSelectedAccount(selected == null ? "" : accountId(selected)); + } + }; + + /** + * True if {@link #init()} hasn't been called. + */ + private static boolean initialized = false; + + static { + accounts.addListener(onInvalidating(Accounts::updateAccountStorages)); + } + + private static void updateAccountStorages() { + // don't update the underlying storage before data loading is completed + // otherwise it might cause data loss + if (!initialized) + return; + // update storage + CONFIG.getAccountStorages().setAll( + accounts.stream() + .map(account -> { + Map storage = account.toStorage(); + storage.put("type", accountType(account)); + return storage; + }) + .collect(toList())); + } + + /** + * Called when it's ready to load accounts from {@link ConfigHolder#CONFIG}. + */ + static void init() { + if (initialized) + throw new IllegalStateException("Already initialized"); + + // load accounts + CONFIG.getAccountStorages().forEach(storage -> { + AccountFactory factory = type2factory.get(storage.get("type")); + if (factory == null) { + LOG.warning("Unrecognized account type: " + storage); + return; + } + Account account; + try { + account = factory.fromStorage(storage); + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to load account: " + storage, e); + return; + } + accounts.add(account); + }); + + initialized = true; + + CONFIG.getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts)); + + // load selected account + selectedAccount.set( + accounts.stream() + .filter(it -> accountId(it).equals(CONFIG.getSelectedAccount())) + .findFirst() + .orElse(null)); + } + + public static ObservableList getAccounts() { + return accounts; + } + + public static ReadOnlyListProperty accountsProperty() { + return accountsWrapper; + } + + public static Account getSelectedAccount() { + return selectedAccount.get(); + } + + public static void setSelectedAccount(Account selectedAccount) { + Accounts.selectedAccount.set(selectedAccount); + } + + public static ObjectProperty selectedAccountProperty() { + return selectedAccount; + } + + // ==== authlib-injector ==== private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) { return CONFIG.getAuthlibInjectorServers().stream() .filter(server -> url.equals(server.getUrl())) @@ -98,4 +230,34 @@ public final class Accounts { return server; }); } + + /** + * After an {@link AuthlibInjectorServer} is removed, the associated accounts should also be removed. + * This method performs a check and removes the dangling accounts. + */ + private static void removeDanglingAuthlibInjectorAccounts() { + accounts.stream() + .filter(AuthlibInjectorAccount.class::isInstance) + .map(AuthlibInjectorAccount.class::cast) + .filter(it -> !CONFIG.getAuthlibInjectorServers().contains(it.getServer())) + .collect(toList()) + .forEach(accounts::remove); + } + // ==== + + // ==== Login type name i18n === + private static Map, String> loginType2name = mapOf( + pair(Accounts.FACTORY_OFFLINE, i18n("account.methods.offline")), + pair(Accounts.FACTORY_YGGDRASIL, i18n("account.methods.yggdrasil")), + pair(Accounts.FACTORY_AUTHLIB_INJECTOR, i18n("account.methods.authlib_injector"))); + + public static String getAccountTypeName(AccountFactory factory) { + return Optional.ofNullable(loginType2name.get(factory)) + .orElseThrow(() -> new IllegalArgumentException("No corresponding login type name")); + } + + public static String getAccountTypeName(Account account) { + return getAccountTypeName(getAccountFactory(account)); + } + // ==== } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java index b1ab82646..e3dbfecb2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java @@ -126,7 +126,7 @@ public final class Config implements Cloneable, Observable { private ObservableMap configurations = FXCollections.observableMap(new TreeMap<>()); @SerializedName("accounts") - private ObservableList> accounts = FXCollections.observableArrayList(); + private ObservableList> accountStorages = FXCollections.observableArrayList(); @SerializedName("selectedAccount") private StringProperty selectedAccount = new SimpleStringProperty(""); @@ -361,8 +361,8 @@ public final class Config implements Cloneable, Observable { return configurations; } - public ObservableList> getAccounts() { - return accounts; + public ObservableList> getAccountStorages() { + return accountStorages; } public String getSelectedAccount() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java index 7d34525ee..08cfb0532 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java @@ -17,16 +17,10 @@ */ package org.jackhuang.hmcl.setting; -import javafx.beans.InvalidationListener; -import javafx.beans.property.ObjectProperty; import javafx.beans.value.ObservableValue; import javafx.scene.text.Font; import org.jackhuang.hmcl.Launcher; -import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.auth.AccountFactory; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.event.*; import org.jackhuang.hmcl.task.Schedulers; @@ -34,82 +28,37 @@ import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.i18n.Locales; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.logging.Level; import java.util.stream.Collectors; -import static java.util.stream.Collectors.toList; import static org.jackhuang.hmcl.setting.ConfigHolder.CONFIG; -import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; -import static org.jackhuang.hmcl.util.Lang.tryCast; import static org.jackhuang.hmcl.util.Logging.LOG; public class Settings { public static final Settings INSTANCE = new Settings(); - private final Map accounts = new ConcurrentHashMap<>(); - private final boolean firstLaunch; - private InvalidationListener accountChangeListener = - source -> CONFIG.getAccounts().setAll( - accounts.values().stream() - .map(account -> { - Map storage = account.toStorage(); - storage.put("type", Accounts.getAccountType(account)); - return storage; - }) - .collect(toList())); - private Settings() { firstLaunch = CONFIG.isFirstLaunch(); CONFIG.setFirstLaunch(false); ProxyManager.init(); - - for (Iterator> iterator = CONFIG.getAccounts().iterator(); iterator.hasNext();) { - Map settings = iterator.next(); - AccountFactory factory = Accounts.TYPE_TO_ACCOUNT_FACTORY.get(tryCast(settings.get("type"), String.class).orElse("")); - if (factory == null) { - LOG.warning("Unrecognized account type, removing: " + settings); - iterator.remove(); - continue; - } - - Account account; - try { - account = factory.fromStorage(settings); - } catch (Exception e) { - LOG.log(Level.WARNING, "Malformed account storage, removing: " + settings, e); - iterator.remove(); - continue; - } - - accounts.put(Accounts.getAccountId(account), account); - account.addListener(accountChangeListener); - } - - CONFIG.getAuthlibInjectorServers().addListener(onInvalidating(this::removeDanglingAuthlibInjectorAccounts)); - - this.selectedAccount.set(accounts.get(CONFIG.getSelectedAccount())); + Accounts.init(); checkProfileMap(); - save(); - for (Map.Entry profileEntry : getProfileMap().entrySet()) { profileEntry.getValue().setName(profileEntry.getKey()); profileEntry.getValue().nameProperty().setChangedListener(this::profileNameChanged); profileEntry.getValue().addPropertyChangedListener(e -> save()); } - Lang.ignoringException(() -> Runtime.getRuntime().addShutdownHook(new Thread(this::save))); - CONFIG.addListener(source -> save()); } private void save() { + LOG.info("Saving config"); ConfigHolder.saveConfig(CONFIG); } @@ -145,23 +94,6 @@ public class Settings { CONFIG.setLogLines(logLines); } - /**************************************** - * AUTHLIB INJECTORS * - ****************************************/ - - /** - * After an {@link AuthlibInjectorServer} is removed, the associated accounts should also be removed. - * This method performs a check and removes the dangling accounts. - * Don't call this before {@link #migrateAuthlibInjectorServers()} is called, otherwise old data would be lost. - */ - private void removeDanglingAuthlibInjectorAccounts() { - accounts.values().stream() - .filter(AuthlibInjectorAccount.class::isInstance) - .filter(it -> !CONFIG.getAuthlibInjectorServers().contains(((AuthlibInjectorAccount) it).getServer())) - .collect(toList()) - .forEach(this::deleteAccount); - } - /**************************************** * DOWNLOAD PROVIDERS * ****************************************/ @@ -177,86 +109,6 @@ public class Settings { CONFIG.setDownloadType(index); } - /**************************************** - * ACCOUNTS * - ****************************************/ - - private final ImmediateObjectProperty selectedAccount = new ImmediateObjectProperty(this, "selectedAccount", null) { - @Override - public Account get() { - Account a = super.get(); - if (a == null || !accounts.containsKey(Accounts.getAccountId(a))) { - Account acc = accounts.values().stream().findAny().orElse(null); - set(acc); - return acc; - } else return a; - } - - @Override - public void set(Account newValue) { - if (newValue == null || accounts.containsKey(Accounts.getAccountId(newValue))) { - super.set(newValue); - } - } - - @Override - public void invalidated() { - super.invalidated(); - - CONFIG.setSelectedAccount(getValue() == null ? "" : Accounts.getAccountId(getValue())); - } - }; - - public Account getSelectedAccount() { - return selectedAccount.get(); - } - - public ObjectProperty selectedAccountProperty() { - return selectedAccount; - } - - public void setSelectedAccount(Account selectedAccount) { - this.selectedAccount.set(selectedAccount); - } - - public void addAccount(Account account) { - accounts.put(Accounts.getAccountId(account), account); - account.addListener(accountChangeListener); - accountChangeListener.invalidated(account); - - onAccountLoading(); - - EventBus.EVENT_BUS.fireEvent(new AccountAddedEvent(this, account)); - } - - public Account getAccount(String name, String character) { - return accounts.get(Accounts.getAccountId(name, character)); - } - - public Collection getAccounts() { - return Collections.unmodifiableCollection(accounts.values()); - } - - public void deleteAccount(String name, String character) { - Account removed = accounts.remove(Accounts.getAccountId(name, character)); - if (removed != null) { - removed.removeListener(accountChangeListener); - accountChangeListener.invalidated(removed); - - onAccountLoading(); - selectedAccount.get(); - } - } - - public void deleteAccount(Account account) { - accounts.remove(Accounts.getAccountId(account)); - account.removeListener(accountChangeListener); - accountChangeListener.invalidated(account); - - onAccountLoading(); - selectedAccount.get(); - } - /**************************************** * PROFILES * ****************************************/ @@ -347,8 +199,4 @@ public class Settings { EventBus.EVENT_BUS.fireEvent(new ProfileLoadingEvent(this, getProfiles())); onProfileChanged(); } - - public void onAccountLoading() { - EventBus.EVENT_BUS.fireEvent(new AccountLoadingEvent(this, getAccounts())); - } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/AccountPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/AccountPage.java index 16052ebee..e2805b61d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/AccountPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/AccountPage.java @@ -35,7 +35,7 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; import org.jackhuang.hmcl.game.AccountHelper; -import org.jackhuang.hmcl.setting.Settings; +import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.setting.Theme; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.construct.ComponentList; @@ -97,7 +97,7 @@ public class AccountPage extends StackPane implements DecoratorPage { btnRefresh.setGraphic(SVG.refresh(Theme.blackFillBinding(), 15, 15)); lblCharacter.setText(account.getCharacter()); - lblType.setText(AddAccountPane.accountType(account)); + lblType.setText(Accounts.getAccountTypeName(account)); lblEmail.setText(account.getUsername()); btnRefresh.setVisible(account instanceof YggdrasilAccount); @@ -105,7 +105,7 @@ public class AccountPage extends StackPane implements DecoratorPage { @FXML private void onDelete() { - Settings.INSTANCE.deleteAccount(account); + Accounts.getAccounts().remove(account); Optional.ofNullable(onDelete.get()).ifPresent(Runnable::run); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/AddAccountPane.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/AddAccountPane.java index 7a9bc7ff9..4d03f2d7f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/AddAccountPane.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/AddAccountPane.java @@ -33,15 +33,12 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.auth.*; -import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; -import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; import org.jackhuang.hmcl.game.AccountHelper; import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.setting.Settings; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.construct.AdvancedListBox; @@ -53,7 +50,6 @@ import org.jackhuang.hmcl.util.Constants; import org.jackhuang.hmcl.util.Logging; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.logging.Level; @@ -62,8 +58,6 @@ import static org.jackhuang.hmcl.setting.ConfigHolder.CONFIG; import static org.jackhuang.hmcl.ui.FXUtils.jfxListCellFactory; import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; -import static org.jackhuang.hmcl.util.Lang.mapOf; -import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class AddAccountPane extends StackPane { @@ -89,12 +83,8 @@ public class AddAccountPane extends StackPane { cboServers.getItems().addListener(onInvalidating(this::selectDefaultServer)); selectDefaultServer(); - Map, String> type2name = mapOf( - pair(Accounts.FACTORY_OFFLINE, i18n("account.methods.offline")), - pair(Accounts.FACTORY_YGGDRASIL, i18n("account.methods.yggdrasil")), - pair(Accounts.FACTORY_AUTHLIB_INJECTOR, i18n("account.methods.authlib_injector"))); - cboType.getItems().setAll(type2name.keySet()); - cboType.setConverter(stringConverter(type2name::get)); + cboType.getItems().setAll(Accounts.FACTORY_OFFLINE, Accounts.FACTORY_YGGDRASIL, Accounts.FACTORY_AUTHLIB_INJECTOR); + cboType.setConverter(stringConverter(Accounts::getAccountTypeName)); cboType.getSelectionModel().select(0); ReadOnlyObjectProperty> loginType = cboType.getSelectionModel().selectedItemProperty(); @@ -157,7 +147,15 @@ public class AddAccountPane extends StackPane { Task.ofResult("create_account", () -> factory.create(new Selector(), username, password, additionalData)) .finalized(Schedulers.javafx(), variables -> { - Settings.INSTANCE.addAccount(variables.get("create_account")); + Account account = variables.get("create_account"); + int oldIndex = Accounts.getAccounts().indexOf(account); + if (oldIndex == -1) { + Accounts.getAccounts().add(account); + } else { + // adding an already-added account + // instead of discarding the new account, we replace the existing account with the new account + Accounts.getAccounts().set(oldIndex, account); + } acceptPane.hideSpinner(); fireEvent(new DialogCloseEvent()); }, exception -> { @@ -276,11 +274,4 @@ public class AddAccountPane extends StackPane { return exception.getClass().getName() + ": " + exception.getLocalizedMessage(); } } - - public static String accountType(Account account) { - if (account instanceof OfflineAccount) return i18n("account.methods.offline"); - else if (account instanceof AuthlibInjectorAccount) return i18n("account.methods.authlib_injector"); - else if (account instanceof YggdrasilAccount) return i18n("account.methods.yggdrasil"); - else throw new Error(i18n("account.methods.no_method") + ": " + account); - } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LeftPaneController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LeftPaneController.java index f0d127d4d..06d0beb70 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LeftPaneController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LeftPaneController.java @@ -21,6 +21,12 @@ import com.jfoenix.concurrency.JFXUtilities; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXPopup; import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.When; +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; import javafx.scene.Node; import javafx.scene.control.Tooltip; import javafx.scene.image.Image; @@ -49,21 +55,39 @@ import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; import org.jackhuang.hmcl.ui.construct.IconedItem; import org.jackhuang.hmcl.ui.construct.RipplerContainer; import org.jackhuang.hmcl.util.Lang; -import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import org.jackhuang.hmcl.util.MappedObservableList; import java.io.File; -import java.util.HashMap; import java.util.LinkedList; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; +import static javafx.collections.FXCollections.singletonObservableList; +import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + public final class LeftPaneController { private final AdvancedListBox leftPane; private final VBox profilePane = new VBox(); private final VBox accountPane = new VBox(); private final IconedItem launcherSettingsItem; - private final VersionListItem missingAccountItem = new VersionListItem(i18n("account.missing"), i18n("message.unknown")); - private final HashMap items = new HashMap<>(); + + private ListProperty accountItems = new SimpleListProperty<>(); + private ObjectProperty selectedAccount = new SimpleObjectProperty() { + { + accountItems.addListener(onInvalidating(this::invalidated)); + } + + @Override + protected void invalidated() { + Account selected = get(); + accountItems.forEach(item -> item.setSelected( + getAccountFromItem(item) + .map(it -> it == selected) + .orElse(false))); + } + }; public LeftPaneController(AdvancedListBox leftPane) { this.leftPane = leftPane; @@ -90,30 +114,91 @@ public final class LeftPaneController { }))) .add(profilePane); - EventBus.EVENT_BUS.channel(AccountAddedEvent.class).register(this::onAccountAdd); - EventBus.EVENT_BUS.channel(AccountLoadingEvent.class).register(this::onAccountsLoading); + // ==== Accounts ==== + // Missing account item + VersionListItem missingAccountItem = new VersionListItem(i18n("account.missing"), i18n("message.unknown")); + RipplerContainer missingAccountRippler = new RipplerContainer(missingAccountItem); + missingAccountItem.setOnSettingsButtonClicked(e -> addNewAccount()); + missingAccountRippler.setOnMouseClicked(e -> addNewAccount()); + + accountItems.bind( + new When(Accounts.accountsProperty().emptyProperty()) + .then(singletonObservableList(missingAccountRippler)) + .otherwise(MappedObservableList.create(Accounts.getAccounts(), this::createAccountItem))); + Bindings.bindContent(accountPane.getChildren(), accountItems); + + selectedAccount.bindBidirectional(Accounts.selectedAccountProperty()); + // ==== + EventBus.EVENT_BUS.channel(ProfileLoadingEvent.class).register(this::onProfilesLoading); EventBus.EVENT_BUS.channel(ProfileChangedEvent.class).register(this::onProfileChanged); EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).register(this::onRefreshedVersions); + } - FXUtils.onChangeAndOperate(Settings.INSTANCE.selectedAccountProperty(), this::onSelectedAccountChanged); - onAccountsLoading(); + // ==== Accounts ==== + private Optional getAccountFromItem(RipplerContainer accountItem) { + return Optional.ofNullable(accountItem.getProperties().get("account")) + .map(Account.class::cast); + } + + private static String accountSubtitle(Account account) { + if (account instanceof OfflineAccount) + return i18n("account.methods.offline"); + else if (account instanceof YggdrasilAccount) + return account.getUsername(); + else + return ""; + } + + private RipplerContainer createAccountItem(Account account) { + VersionListItem item = new VersionListItem(account.getCharacter(), accountSubtitle(account)); + RipplerContainer rippler = new RipplerContainer(item); + item.setOnSettingsButtonClicked(e -> { + AccountPage accountPage = new AccountPage(account, item); + JFXPopup popup = new JFXPopup(accountPage); + accountPage.setOnDelete(popup::hide); + popup.show((Node) e.getSource(), JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, e.getX(), e.getY()); + }); + rippler.setOnMouseClicked(e -> { + if (e.getButton() == MouseButton.PRIMARY) { + selectedAccount.set(account); + } + }); + rippler.getProperties().put("account", account); + rippler.maxWidthProperty().bind(leftPane.widthProperty()); + + if (account instanceof YggdrasilAccount) { + Image image = AccountHelper.getSkin((YggdrasilAccount) account, 4); + item.setImage(image, AccountHelper.getViewport(4)); + } else { + item.setImage(AccountHelper.getDefaultSkin(account.getUUID(), 4), AccountHelper.getViewport(4)); + } + + if (account instanceof AuthlibInjectorAccount) { + FXUtils.installTooltip(rippler, 500, 5000, 0, new Tooltip(((AuthlibInjectorAccount) account).getServer().getName())); + } + + // update skin + if (account instanceof YggdrasilAccount) { + AccountHelper.refreshSkinAsync((YggdrasilAccount) account) + .subscribe(Schedulers.javafx(), () -> { + Image image = AccountHelper.getSkin((YggdrasilAccount) account, 4); + item.setImage(image, AccountHelper.getViewport(4)); + }); + } + + return rippler; + } + + public void checkAccount() { + if (Accounts.getAccounts().isEmpty()) + addNewAccount(); } private void addNewAccount() { Controllers.dialog(new AddAccountPane()); } - - private void onSelectedAccountChanged(Account newAccount) { - Platform.runLater(() -> { - for (Node node : accountPane.getChildren()) { - if (node instanceof RipplerContainer && node.getProperties().get("account") instanceof Account) { - boolean current = Objects.equals(node.getProperties().get("account"), newAccount); - ((RipplerContainer) node).setSelected(current); - } - } - }); - } + // ==== private void onProfileChanged(ProfileChangedEvent event) { Profile profile = event.getProfile(); @@ -143,70 +228,6 @@ public final class LeftPaneController { Platform.runLater(() -> profilePane.getChildren().setAll(list)); } - private static String accountType(Account account) { - if (account instanceof OfflineAccount) return i18n("account.methods.offline"); - else if (account instanceof YggdrasilAccount) return account.getUsername(); - else throw new Error(i18n("account.methods.no_method") + ": " + account); - } - - private void onAccountAdd(AccountAddedEvent event) { - Account account = event.getAccount(); - VersionListItem item = items.get(account); - if (account instanceof YggdrasilAccount) - AccountHelper.refreshSkinAsync((YggdrasilAccount) account) - .subscribe(Schedulers.javafx(), () -> { - Image image = AccountHelper.getSkin((YggdrasilAccount) account, 4); - item.setImage(image, AccountHelper.getViewport(4)); - }); - } - - private void onAccountsLoading() { - LinkedList list = new LinkedList<>(); - items.clear(); - Account selectedAccount = Settings.INSTANCE.getSelectedAccount(); - for (Account account : Settings.INSTANCE.getAccounts()) { - VersionListItem item = new VersionListItem(account.getCharacter(), accountType(account)); - items.put(account, item); - RipplerContainer ripplerContainer = new RipplerContainer(item); - item.setOnSettingsButtonClicked(e -> { - AccountPage accountPage = new AccountPage(account, item); - JFXPopup popup = new JFXPopup(accountPage); - accountPage.setOnDelete(popup::hide); - popup.show((Node) e.getSource(), JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, e.getX(), e.getY()); - }); - ripplerContainer.setOnMouseClicked(e -> { - if (e.getButton() == MouseButton.PRIMARY) - Settings.INSTANCE.setSelectedAccount(account); - }); - ripplerContainer.getProperties().put("account", account); - ripplerContainer.maxWidthProperty().bind(leftPane.widthProperty()); - - if (account instanceof YggdrasilAccount) { - Image image = AccountHelper.getSkin((YggdrasilAccount) account, 4); - item.setImage(image, AccountHelper.getViewport(4)); - } else - item.setImage(AccountHelper.getDefaultSkin(account.getUUID(), 4), AccountHelper.getViewport(4)); - - if (account instanceof AuthlibInjectorAccount) { - FXUtils.installTooltip(ripplerContainer, 500, 5000, 0, new Tooltip(((AuthlibInjectorAccount) account).getServer().getName())); - } - - if (selectedAccount == account) - ripplerContainer.setSelected(true); - - list.add(ripplerContainer); - } - - if (Settings.INSTANCE.getAccounts().isEmpty()) { - RipplerContainer container = new RipplerContainer(missingAccountItem); - missingAccountItem.setOnSettingsButtonClicked(e -> addNewAccount()); - container.setOnMouseClicked(e -> addNewAccount()); - list.add(container); - } - - Platform.runLater(() -> accountPane.getChildren().setAll(list)); - } - public void showUpdate() { launcherSettingsItem.setText(i18n("update.found")); launcherSettingsItem.setTextFill(Color.RED); @@ -247,9 +268,4 @@ public final class LeftPaneController { } }); } - - public void checkAccount() { - if (Settings.INSTANCE.getAccounts().isEmpty()) - addNewAccount(); - } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/MainPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/MainPage.java index f1c30cf78..c4664f3c2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/MainPage.java @@ -37,6 +37,7 @@ import org.jackhuang.hmcl.event.RefreshingVersionsEvent; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.mod.MismatchedModpackTypeException; import org.jackhuang.hmcl.mod.UnsupportedModpackException; +import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.Settings; import org.jackhuang.hmcl.task.Schedulers; @@ -132,13 +133,13 @@ public final class MainPage extends StackPane implements DecoratorPage { }); item.setVersionName(id); item.setOnLaunchButtonClicked(e -> { - if (Settings.INSTANCE.getSelectedAccount() == null) + if (Accounts.getSelectedAccount() == null) Controllers.getLeftPaneController().checkAccount(); else - LauncherHelper.INSTANCE.launch(profile, Settings.INSTANCE.getSelectedAccount(), id, null); + LauncherHelper.INSTANCE.launch(profile, Accounts.getSelectedAccount(), id, null); }); item.setOnScriptButtonClicked(e -> { - if (Settings.INSTANCE.getSelectedAccount() == null) + if (Accounts.getSelectedAccount() == null) Controllers.dialog(i18n("login.empty_username")); else { FileChooser chooser = new FileChooser(); @@ -150,7 +151,7 @@ public final class MainPage extends StackPane implements DecoratorPage { : new FileChooser.ExtensionFilter(i18n("extension.sh"), "*.sh")); File file = chooser.showSaveDialog(Controllers.getStage()); if (file != null) - LauncherHelper.INSTANCE.launch(profile, Settings.INSTANCE.getSelectedAccount(), id, file); + LauncherHelper.INSTANCE.launch(profile, Accounts.getSelectedAccount(), id, file); } }); item.setOnSettingsButtonClicked(e -> { @@ -214,10 +215,10 @@ public final class MainPage extends StackPane implements DecoratorPage { }); versionPopup.show(item, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.LEFT, event.getX(), event.getY()); } else if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { - if (Settings.INSTANCE.getSelectedAccount() == null) + if (Accounts.getSelectedAccount() == null) Controllers.dialog(i18n("login.empty_username")); else - LauncherHelper.INSTANCE.launch(profile, Settings.INSTANCE.getSelectedAccount(), id, null); + LauncherHelper.INSTANCE.launch(profile, Accounts.getSelectedAccount(), id, null); } }); File iconFile = repository.getVersionIcon(id); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java index 39613db3c..a72bb0baa 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java @@ -28,7 +28,7 @@ import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; import org.jackhuang.hmcl.Launcher; import org.jackhuang.hmcl.auth.Account; -import org.jackhuang.hmcl.setting.Settings; +import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.wizard.WizardController; @@ -67,7 +67,7 @@ public final class ModpackInfoPage extends StackPane implements WizardPage { txtModpackName.textProperty().addListener(e -> checkValidation()); txtModpackAuthor.textProperty().addListener(e -> checkValidation()); txtModpackVersion.textProperty().addListener(e -> checkValidation()); - txtModpackAuthor.setText(Optional.ofNullable(Settings.INSTANCE.getSelectedAccount()).map(Account::getUsername).orElse("")); + txtModpackAuthor.setText(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getUsername).orElse("")); lblVersionName.setText(version); List launcherJar = Launcher.getCurrentJarFiles(); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 9a69307a5..20af97280 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -49,7 +49,6 @@ account.injector.server_url=Server URL account.injector.server_name=Server Name account.methods=Login Type account.methods.authlib_injector=authlib-injector -account.methods.no_method=No login method account.methods.offline=Offline account.methods.yggdrasil=Mojang account.missing=None diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 7f8c2e010..994820482 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -49,7 +49,6 @@ account.injector.server_url=服務器地址 account.injector.server_name=服務器名稱 account.methods=登錄方式 account.methods.authlib_injector=authlib-injector 登錄 -account.methods.no_method=沒有登入方式 account.methods.offline=離線模式 account.methods.yggdrasil=正版登錄 account.missing=沒有賬戶 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 33957fee0..7bd085184 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -49,7 +49,6 @@ account.injector.server_url=服务器地址 account.injector.server_name=服务器名称 account.methods=登录方式 account.methods.authlib_injector=authlib-injector 登录 -account.methods.no_method=没有登入方式 account.methods.offline=离线模式 account.methods.yggdrasil=正版登录 account.missing=没有账户 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java index 6ab45f496..43c40ccb2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/authlibinjector/AuthlibInjectorAccount.java @@ -31,6 +31,7 @@ import org.jackhuang.hmcl.util.NetworkUtils; import java.util.Base64; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.logging.Level; @@ -110,4 +111,16 @@ public class AuthlibInjectorAccount extends YggdrasilAccount { return server; } + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), server.hashCode()); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof AuthlibInjectorAccount)) + return false; + AuthlibInjectorAccount another = (AuthlibInjectorAccount) obj; + return super.equals(another) && server.equals(another.server); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java index f4925d9d5..8b294fd83 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/OfflineAccount.java @@ -105,4 +105,17 @@ public class OfflineAccount extends Account { .append("uuid", uuid) .toString(); } + + @Override + public int hashCode() { + return username.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof OfflineAccount)) + return false; + OfflineAccount another = (OfflineAccount) obj; + return username.equals(another.username); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java index 4d47c1f8f..b55d5b9c0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/yggdrasil/YggdrasilAccount.java @@ -169,4 +169,16 @@ public class YggdrasilAccount extends Account { return "YggdrasilAccount[username=" + getUsername() + "]"; } + @Override + public int hashCode() { + return characterUUID.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof YggdrasilAccount)) + return false; + YggdrasilAccount another = (YggdrasilAccount) obj; + return characterUUID.equals(another.characterUUID); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MappedObservableList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MappedObservableList.java new file mode 100644 index 000000000..da7684e5b --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/MappedObservableList.java @@ -0,0 +1,160 @@ +/* + * Hello Minecraft! Launcher. + * Copyright (C) 2018 huangyuhui + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see {http://www.gnu.org/licenses/}. + */ +package org.jackhuang.hmcl.util; + +import static java.util.stream.Collectors.toCollection; +import static javafx.collections.FXCollections.unmodifiableObservableList; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.collections.WeakListChangeListener; + +/** + * @author yushijinhun + */ +public final class MappedObservableList { + private MappedObservableList() { + } + + private static class ReferenceHolder implements InvalidationListener { + @SuppressWarnings("unused") + private Object ref; + + ReferenceHolder(Object ref) { + this.ref = ref; + } + + @Override + public void invalidated(Observable observable) { + // no-op + } + } + + private static class MappedObservableListUpdater implements ListChangeListener { + private ObservableList origin; + private ObservableList target; + private Function mapper; + + // If we directly synchronize changes to target, each operation on target will cause a event to be fired. + // So we first write changes to buffer. After all the changes are processed, we use target.setAll to synchronize the changes. + private List buffer; + + MappedObservableListUpdater(ObservableList origin, ObservableList target, Function mapper) { + this.origin = origin; + this.target = target; + this.mapper = mapper; + this.buffer = new ArrayList<>(target); + } + + @Override + public void onChanged(Change change) { + // cache removed elements to reduce calls to mapper + Map> cache = new HashMap<>(); + + while (change.next()) { + int from = change.getFrom(); + int to = change.getTo(); + + if (change.wasPermutated()) { + @SuppressWarnings("unchecked") + U[] temp = (U[]) new Object[to - from]; + for (int i = 0; i < temp.length; i++) { + temp[i] = buffer.get(from + i); + } + + for (int idx = from; idx < to; idx++) { + buffer.set(change.getPermutation(idx), temp[idx - from]); + } + } else { + if (change.wasRemoved()) { + List originRemoved = change.getRemoved(); + List targetRemoved = buffer.subList(from, from + originRemoved.size()); + for (int i = 0; i < targetRemoved.size(); i++) { + pushCache(cache, originRemoved.get(i), targetRemoved.get(i)); + } + targetRemoved.clear(); + } + if (change.wasAdded()) { + @SuppressWarnings("unchecked") + U[] toAdd = (U[]) new Object[to - from]; + for (int i = 0; i < toAdd.length; i++) { + toAdd[i] = map(cache, origin.get(from + i)); + } + buffer.addAll(from, Arrays.asList(toAdd)); + } + } + } + target.setAll(buffer); + } + + private void pushCache(Map> cache, T key, U value) { + cache.computeIfAbsent(key, any -> new LinkedList<>()) + .push(value); + } + + private U map(Map> cache, T key) { + LinkedList stack = cache.get(key); + if (stack != null && !stack.isEmpty()) { + return stack.pop(); + } + return mapper.apply(key); + } + } + + /** + * This methods creates a mapping of {@code origin}, using {@code mapper} as the converter. + * + * If an item is added to {@code origin}, {@code mapper} will be invoked to create a corresponding item, which will also be added to the returned {@code ObservableList}. + * If an item is removed from {@code origin}, the corresponding item in the returned {@code ObservableList} will also be removed. + * If {@code origin} is permutated, the returned {@code ObservableList} will also be permutated in the same way. + * + * The returned {@code ObservableList} is unmodifiable. + */ + public static ObservableList create(ObservableList origin, Function mapper) { + // create a already-synchronized target ObservableList + ObservableList target = origin.stream() + .map(mapper) + .collect(toCollection(FXCollections::observableArrayList)); + + // then synchronize further changes to target + ListChangeListener listener = new MappedObservableListUpdater<>(origin, target, mapper); + + // let target hold a reference to listener to prevent listener being garbage-collected before target is garbage-collected + target.addListener(new ReferenceHolder(listener)); + + // let origin hold a weak reference to listener, so that target can be garbage-collected when it's no longer used + origin.addListener(new WeakListChangeListener<>(listener)); + + // ref graph: + // target ------> listener <-weak- origin + // <------ ------> + + return unmodifiableObservableList(target); + } +}